561 lines
21 KiB
TypeScript
561 lines
21 KiB
TypeScript
/**
|
||
* 插件宿主(主进程):
|
||
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
||
* - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
|
||
* (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
|
||
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
|
||
* manifest.replacesNative
|
||
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
|
||
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
|
||
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
|
||
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
|
||
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
|
||
*
|
||
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
|
||
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
|
||
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
|
||
*
|
||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||
* ed25519 验签 + 开发者模式豁免)。
|
||
*/
|
||
import { app, BrowserWindow, net, protocol } from 'electron'
|
||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||
import { join, resolve, sep } from 'node:path'
|
||
import { pathToFileURL } from 'node:url'
|
||
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
||
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||
|
||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||
export const HARNESS_CONTRACT_VERSION = '0.1.0'
|
||
|
||
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
|
||
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
|
||
|
||
interface ResolvedPlugin {
|
||
manifest: PluginManifest
|
||
source: 'builtin' | 'external'
|
||
/** 外置来源目录类别(管理页分组展示用) */
|
||
origin?: 'user' | 'project' | 'global'
|
||
/** 外置插件根目录(plugin:// 映射根) */
|
||
dir?: string
|
||
/** 内置:第一方工厂 / 渲染层组件 key */
|
||
builtinFactory?: HarnessPluginFactory
|
||
componentKey?: string
|
||
/** 外置 main.mjs 动态导入的工厂(reload 时替换) */
|
||
externalFactory?: HarnessPluginFactory
|
||
/** 装载/激活错误(管理页展示;不阻塞其他插件) */
|
||
error?: string
|
||
}
|
||
|
||
/** 最小 semver(x.y.z 数值三元组;忽略预发布段) */
|
||
function parseSemver(v: string): [number, number, number] | null {
|
||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())
|
||
if (!m) return null
|
||
return [Number(m[1]), Number(m[2]), Number(m[3])]
|
||
}
|
||
|
||
function compareSemver(a: string, b: string): number {
|
||
const pa = parseSemver(a)
|
||
const pb = parseSemver(b)
|
||
if (!pa || !pb) return 0
|
||
for (let i = 0; i < 3; i++) {
|
||
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
class PluginHost {
|
||
private resolved = new Map<string, ResolvedPlugin>()
|
||
private disabled = new Set<string>()
|
||
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
|
||
private projectExtensionsDir: string | null = null
|
||
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
|
||
private rebuildRuntime: (() => Promise<void>) | null = null
|
||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||
/** 插件 KV 存储(pluginId → key → value) */
|
||
private storage = new Map<string, Map<string, unknown>>()
|
||
private storageSaveTimer: NodeJS.Timeout | null = null
|
||
|
||
private get externalRoot(): string {
|
||
return join(app.getPath('userData'), 'plugins')
|
||
}
|
||
|
||
private get settingsFile(): string {
|
||
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
||
}
|
||
|
||
private get storageFile(): string {
|
||
return join(app.getPath('userData'), 'aso-plugin-storage.json')
|
||
}
|
||
|
||
// ---- 生命周期 ----
|
||
|
||
init(): void {
|
||
try {
|
||
mkdirSync(this.externalRoot, { recursive: true })
|
||
} catch {
|
||
// 已存在或创建失败都继续(扫描时再报)
|
||
}
|
||
try {
|
||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
|
||
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
|
||
} catch {
|
||
// 无设置文件 = 全部启用
|
||
}
|
||
try {
|
||
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
|
||
for (const [pid, kv] of Object.entries(raw)) {
|
||
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
|
||
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
|
||
}
|
||
}
|
||
} catch {
|
||
// 无存储文件 = 空
|
||
}
|
||
void this.scan()
|
||
}
|
||
|
||
setRuntimeRebuilder(fn: () => Promise<void>): void {
|
||
this.rebuildRuntime = fn
|
||
}
|
||
|
||
/** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 UI,不重建 runtime) */
|
||
setProjectExtensionsDir(dir: string | null): void {
|
||
const next = dir ?? null
|
||
if ((this.projectExtensionsDir ?? null) === next) return
|
||
this.projectExtensionsDir = next
|
||
void this.scan()
|
||
}
|
||
|
||
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建)。
|
||
* 外置源优先级:userData/plugins > 项目 .pi/extensions > 全局 ~/.pi/agent/extensions。 */
|
||
async scan(): Promise<void> {
|
||
const byId = new Map<string, ResolvedPlugin>()
|
||
|
||
for (const builtin of BUILTIN_PLUGINS) {
|
||
byId.set(builtin.manifest.id, {
|
||
manifest: builtin.manifest,
|
||
source: 'builtin',
|
||
builtinFactory: builtin.main,
|
||
componentKey: builtin.component
|
||
})
|
||
}
|
||
|
||
if (EXTERNAL_PLUGINS_ENABLED) {
|
||
const roots: Array<{ root: string; origin: 'user' | 'project' | 'global' }> = [
|
||
{ root: this.externalRoot, origin: 'user' }
|
||
]
|
||
if (this.projectExtensionsDir) roots.push({ root: this.projectExtensionsDir, origin: 'project' })
|
||
roots.push({ root: join(getAgentDir(), 'extensions'), origin: 'global' })
|
||
for (const { root, origin } of roots) {
|
||
if (!existsSync(root)) continue
|
||
let entries: import('node:fs').Dirent[]
|
||
try {
|
||
entries = readdirSync(root, { withFileTypes: true })
|
||
} catch {
|
||
entries = []
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue
|
||
const dir = join(root, entry.name)
|
||
const manifest = this.readManifest(dir)
|
||
if (!manifest) continue
|
||
const existing = byId.get(manifest.id)
|
||
if (existing) {
|
||
const cmp = compareSemver(manifest.version, existing.manifest.version)
|
||
// 仅严格更高版本可遮蔽;平级先扫者胜
|
||
if (cmp <= 0) continue
|
||
// 组件级内置只能被显式声明降级替代的外置遮蔽
|
||
if (existing.componentKey && !manifest.replacesNative) {
|
||
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
|
||
continue
|
||
}
|
||
}
|
||
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
|
||
byId.set(manifest.id, {
|
||
manifest,
|
||
source: 'external',
|
||
dir,
|
||
origin,
|
||
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
|
||
})
|
||
continue
|
||
}
|
||
byId.set(manifest.id, { manifest, source: 'external', dir, origin })
|
||
}
|
||
}
|
||
}
|
||
|
||
this.resolved = byId
|
||
await this.loadExternalMains()
|
||
this.send('plugins:listChanged', null)
|
||
}
|
||
|
||
private readManifest(dir: string): PluginManifest | null {
|
||
const file = join(dir, 'manifest.json')
|
||
try {
|
||
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<PluginManifest>
|
||
if (typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.version !== 'string') return null
|
||
if (!/^[a-z][a-z0-9-]*$/.test(raw.id)) return null
|
||
if (!parseSemver(raw.version) || !parseSemver(raw.minContract ?? '0.0.0')) return null
|
||
return {
|
||
id: raw.id,
|
||
name: raw.name,
|
||
version: raw.version,
|
||
minContract: raw.minContract ?? '0.0.0',
|
||
description: typeof raw.description === 'string' ? raw.description : undefined,
|
||
icon: typeof raw.icon === 'string' ? raw.icon : undefined,
|
||
order: typeof raw.order === 'number' ? raw.order : undefined,
|
||
panel: raw.panel === true,
|
||
entry:
|
||
raw.entry && typeof raw.entry === 'object'
|
||
? {
|
||
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
|
||
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined,
|
||
renderer: typeof raw.entry.renderer === 'string' ? raw.entry.renderer : undefined
|
||
}
|
||
: undefined,
|
||
replacesNative: raw.replacesNative === true,
|
||
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** 动态导入外置 main.mjs(cache-bust 保证重载拿到新代码) */
|
||
private async loadExternalMains(): Promise<void> {
|
||
for (const plugin of this.resolved.values()) {
|
||
if (plugin.source !== 'external' || !plugin.manifest.entry?.main || plugin.error) continue
|
||
const file = resolve(plugin.dir!, plugin.manifest.entry.main)
|
||
try {
|
||
const url = pathToFileURL(file).href + `?t=${Date.now()}`
|
||
const mod = (await import(url)) as { default?: unknown }
|
||
if (typeof mod.default !== 'function') {
|
||
plugin.error = 'main.mjs 默认导出必须是插件工厂函数'
|
||
continue
|
||
}
|
||
plugin.externalFactory = mod.default as HarnessPluginFactory
|
||
plugin.error = undefined
|
||
} catch (err) {
|
||
plugin.externalFactory = undefined
|
||
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
||
async reload(): Promise<void> {
|
||
this.statusItems.clear()
|
||
this.toolbarButtons.clear()
|
||
await this.scan()
|
||
this.send('plugins:uiState', this.uiState())
|
||
if (this.rebuildRuntime) await this.rebuildRuntime()
|
||
}
|
||
|
||
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
||
if (enabled) this.disabled.delete(id)
|
||
else this.disabled.add(id)
|
||
try {
|
||
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
|
||
} catch (err) {
|
||
console.error('[plugins] save settings failed:', err)
|
||
}
|
||
await this.reload()
|
||
}
|
||
|
||
// ---- 查询 ----
|
||
|
||
isEnabled(id: string): boolean {
|
||
return !this.disabled.has(id)
|
||
}
|
||
|
||
list(): PluginInfo[] {
|
||
return [...this.resolved.values()]
|
||
.map((p) => ({
|
||
manifest: p.manifest,
|
||
source: p.source,
|
||
origin: p.origin,
|
||
enabled: this.isEnabled(p.manifest.id),
|
||
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
|
||
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
||
hasComponent: Boolean(p.componentKey),
|
||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||
error: p.error
|
||
}))
|
||
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
||
}
|
||
|
||
getComponentKey(id: string): string | undefined {
|
||
const p = this.resolved.get(id)
|
||
return p?.componentKey
|
||
}
|
||
|
||
/** 外置插件来源目录(管理页「打开目录」用) */
|
||
pluginDirs(): { user: string[]; project: string[]; global: string[] } {
|
||
return {
|
||
user: [this.externalRoot],
|
||
project: this.projectExtensionsDir ? [this.projectExtensionsDir] : [],
|
||
global: [join(getAgentDir(), 'extensions')]
|
||
}
|
||
}
|
||
|
||
/** 读取外置插件渲染层模块源码。渲染层取回后以 Blob URL import——
|
||
* dev 下页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦截。 */
|
||
readRendererSource(id: string): string {
|
||
if (!EXTERNAL_PLUGINS_ENABLED) throw new Error('第三方插件装载已关闭(用户版构建)')
|
||
const plugin = this.resolved.get(id)
|
||
const file =
|
||
plugin?.source === 'external' && plugin.manifest.entry?.renderer
|
||
? resolve(plugin.dir!, plugin.manifest.entry.renderer)
|
||
: null
|
||
if (!file || !existsSync(file)) throw new Error(`插件 ${id} 未提供渲染层模块`)
|
||
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('渲染层模块超过 8MB 上限')
|
||
return readFileSync(file, 'utf8')
|
||
}
|
||
|
||
uiState(): PluginsUiState {
|
||
const status: PluginStatusEntry[] = []
|
||
for (const [pluginId, items] of this.statusItems) {
|
||
for (const [itemId, item] of items) status.push({ pluginId, itemId, item })
|
||
}
|
||
const toolbar: PluginToolbarEntry[] = []
|
||
for (const [pluginId, buttons] of this.toolbarButtons) {
|
||
for (const [buttonId, button] of buttons) toolbar.push({ pluginId, buttonId, button })
|
||
}
|
||
return { status, toolbar }
|
||
}
|
||
|
||
// ---- 内联 UI(数据驱动;单一事实源在主进程) ----
|
||
|
||
setStatusItem(pluginId: string, itemId: string, item: StatusItem | null): void {
|
||
if (!this.resolved.has(pluginId)) return
|
||
let items = this.statusItems.get(pluginId)
|
||
if (!items) {
|
||
items = new Map()
|
||
this.statusItems.set(pluginId, items)
|
||
}
|
||
if (item) items.set(itemId, item)
|
||
else items.delete(itemId)
|
||
if (items.size === 0) this.statusItems.delete(pluginId)
|
||
this.send('plugins:uiState', this.uiState())
|
||
}
|
||
|
||
registerToolbarButton(pluginId: string, button: ToolbarButtonDef): void {
|
||
if (!this.resolved.has(pluginId)) return
|
||
let buttons = this.toolbarButtons.get(pluginId)
|
||
if (!buttons) {
|
||
buttons = new Map()
|
||
this.toolbarButtons.set(pluginId, buttons)
|
||
}
|
||
buttons.set(button.id, button)
|
||
this.send('plugins:uiState', this.uiState())
|
||
}
|
||
|
||
removeToolbarButton(pluginId: string, buttonId: string): void {
|
||
const buttons = this.toolbarButtons.get(pluginId)
|
||
if (!buttons) return
|
||
buttons.delete(buttonId)
|
||
if (buttons.size === 0) this.toolbarButtons.delete(pluginId)
|
||
this.send('plugins:uiState', this.uiState())
|
||
}
|
||
|
||
/** 清空某插件的全部内联 UI(禁用/重载路径) */
|
||
private clearPluginUi(pluginId: string): void {
|
||
this.statusItems.delete(pluginId)
|
||
this.toolbarButtons.delete(pluginId)
|
||
this.send('plugins:uiState', this.uiState())
|
||
}
|
||
|
||
// ---- 深链 ----
|
||
|
||
openView(view: string, params?: unknown): void {
|
||
this.send('plugins:view', { view, params })
|
||
}
|
||
|
||
// ---- 插件 KV 存储 ----
|
||
|
||
storageFor(pluginId: string) {
|
||
return {
|
||
get: <T>(key: string): T | undefined => {
|
||
const kv = this.storage.get(pluginId)
|
||
const v = kv?.get(key)
|
||
return v === undefined ? undefined : (structuredClone(v) as T)
|
||
},
|
||
set: (key: string, value: unknown): void => {
|
||
let kv = this.storage.get(pluginId)
|
||
if (!kv) {
|
||
kv = new Map()
|
||
this.storage.set(pluginId, kv)
|
||
}
|
||
kv.set(key, structuredClone(value))
|
||
this.scheduleStorageSave()
|
||
},
|
||
delete: (key: string): void => {
|
||
this.storage.get(pluginId)?.delete(key)
|
||
this.scheduleStorageSave()
|
||
}
|
||
}
|
||
}
|
||
|
||
private scheduleStorageSave(): void {
|
||
if (this.storageSaveTimer) return
|
||
this.storageSaveTimer = setTimeout(() => {
|
||
this.storageSaveTimer = null
|
||
const doc: Record<string, Record<string, unknown>> = {}
|
||
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
|
||
try {
|
||
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
|
||
} catch (err) {
|
||
console.error('[plugins] save storage failed:', err)
|
||
}
|
||
}, 300)
|
||
}
|
||
|
||
// ---- 聚合 pi extension ----
|
||
|
||
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
|
||
createAggregatedExtension(): InlineExtension {
|
||
return {
|
||
name: 'aso-plugins',
|
||
factory: (pi: ExtensionAPI) => {
|
||
const disposers: Array<() => void> = []
|
||
for (const plugin of this.resolved.values()) {
|
||
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
|
||
const factory = plugin.builtinFactory ?? plugin.externalFactory
|
||
if (!factory) continue
|
||
try {
|
||
const result = factory(this.buildApi(plugin, pi))
|
||
if (typeof result === 'function') {
|
||
disposers.push(result)
|
||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||
void (result as Promise<void | (() => void)>).then((d) => {
|
||
if (typeof d === 'function') disposers.push(d)
|
||
})
|
||
}
|
||
} catch (err) {
|
||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||
this.clearPluginUi(plugin.manifest.id)
|
||
this.send('plugins:listChanged', null)
|
||
}
|
||
}
|
||
pi.on('session_shutdown', () => {
|
||
for (const dispose of disposers) {
|
||
try {
|
||
dispose()
|
||
} catch (err) {
|
||
console.error('[plugins] dispose error:', err)
|
||
}
|
||
}
|
||
disposers.length = 0
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
|
||
const id = plugin.manifest.id
|
||
return {
|
||
pi,
|
||
manifest: plugin.manifest,
|
||
storage: this.storageFor(id),
|
||
ui: {
|
||
openView: (view: string, params?: unknown) => this.openView(view, params),
|
||
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
|
||
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
|
||
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
|
||
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- plugin:// 协议 ----
|
||
|
||
/** plugin://<id>/<rel> → 插件目录内文件;路径必须落在该插件根内(防穿越) */
|
||
resolvePluginFile(id: string, rel: string): string | null {
|
||
const plugin = this.resolved.get(id)
|
||
if (!plugin || plugin.source !== 'external' || !plugin.dir) return null
|
||
const root = resolve(plugin.dir)
|
||
const target = resolve(root, rel)
|
||
if (target !== root && !target.startsWith(root + sep)) return null
|
||
return target
|
||
}
|
||
|
||
registerProtocols(): void {
|
||
protocol.handle('plugin', (request) => this.serveFile(() => {
|
||
const url = new URL(request.url)
|
||
const id = url.hostname
|
||
const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '')
|
||
const file = this.resolvePluginFile(id, rel)
|
||
return file ?? ''
|
||
}))
|
||
|
||
// peer:// 只暴露固定白名单资产(tokens.css 主题变量表,插件页 <link> 引用)
|
||
protocol.handle('peer', (request) => this.serveFile(() => {
|
||
const url = new URL(request.url)
|
||
const asset = `${url.hostname}${url.pathname}`.replace(/\/+$/, '')
|
||
if (asset !== 'assets/tokens.css') return ''
|
||
return this.tokensCssPath()
|
||
}))
|
||
}
|
||
|
||
private tokensCssPath(): string {
|
||
// dev:源文件;prod:构建期拷贝到 out/renderer/tokens.css(vite closeBundle 插件)
|
||
const src = resolve(app.getAppPath(), 'src', 'renderer', 'src', 'styles', 'beautifului.css')
|
||
if (existsSync(src)) return src
|
||
return resolve(app.getAppPath(), 'out', 'renderer', 'tokens.css')
|
||
}
|
||
|
||
private async serveFile(resolvePath: () => string): Promise<Response> {
|
||
let file = ''
|
||
try {
|
||
file = resolvePath()
|
||
} catch {
|
||
file = ''
|
||
}
|
||
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
|
||
try {
|
||
const res = await net.fetch(pathToFileURL(file).toString())
|
||
// .mjs/.js 必须以 JS MIME 返回——渲染层动态 import(ESM 模块加载)强校验 content-type
|
||
const lower = file.toLowerCase()
|
||
const contentType =
|
||
lower.endsWith('.mjs') || lower.endsWith('.js')
|
||
? 'text/javascript'
|
||
: (res.headers.get('content-type') ?? 'application/octet-stream')
|
||
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
|
||
return new Response(res.body, {
|
||
status: res.status,
|
||
headers: {
|
||
'content-type': contentType,
|
||
'cache-control': 'no-store'
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error('[plugins] serve failed:', file, err)
|
||
return new Response('serve error', { status: 500 })
|
||
}
|
||
}
|
||
|
||
// ---- 渲染层广播 ----
|
||
|
||
private send(channel: string, payload: unknown): void {
|
||
for (const win of BrowserWindow.getAllWindows()) {
|
||
if (!win.isDestroyed()) win.webContents.send(channel, payload)
|
||
}
|
||
}
|
||
}
|
||
|
||
let host: PluginHost | null = null
|
||
|
||
export function getPluginHost(): PluginHost {
|
||
if (!host) host = new PluginHost()
|
||
return host
|
||
}
|
||
|
||
export type { BuiltinPlugin }
|