插件的生命周期管理
概述
插件生命周期管理是插件化架构的核心环节,涵盖插件从发布、安装、运行到卸载的完整流程。
┌─────────────────────────────────────────────────────────────────────┐
│ 插件生命周期流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 发布 │───►│ 安装 │───►│ 运行 │───►│ 卸载 │ │
│ │ Publish │ │ Install │ │ Run │ │ Uninstall│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ npm/oss │ │ 下载/解压 │ │ 加载/初始化│ │ 清理资源 │ │
│ │ 上传发布 │ │ 安全校验 │ │ 运行监控 │ │ 删除文件 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ │
│ │ 更新 │ │
│ │ Update │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 版本检查 │ │
│ │ 热更新 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘插件的发布
插件开发的最终目的是让用户能够方便地获取和使用。为此需要将插件发布到云端,形成一个中心化的插件市场。目前主流的插件发布方式有两种:
OSS 资源服务器模式
将插件打包成特定格式(如 uTools 的 .upx 文件),然后上传到对象存储服务(OSS)上。用户通过下载并安装这些打包文件来使用插件
- 优点:简单直接,易于理解
- 缺点:
- 依赖管理困难:所有依赖项必须完整打包,导致插件体积较大
- 更新机制复杂:需要自行实现版本检查和更新逻辑
- 部署灵活性差:强依赖于特定的 OSS 服务
npm 包管理器模式
将每个插件作为独立的 npm 包发布到 npm registry。这种方式在现代插件化架构中越来越受欢迎
- 优点:
- 自由化部署:通过切换 npm registry(如官方源、私有源),可以轻松实现公网和内网的自由部署
- 高效的依赖管理:利用 npm 的包管理机制,可以自动处理依赖关系,避免重复安装,有效减小插件总体积
- 成熟的生态:可以复用 npm 生态中海量的工具和库
发布流程:
发布插件非常简单,只需在插件项目根目录下执行:
$ npm publish注意:在发布前,请务必确认当前的 npm registry 是否符合预期。例如:若要发布到内部网络,需先将 registry 切换至内网源。可以通过以下命令检查当前的 registry:
$ npm config get registry插件的目录结构
标准的插件项目遵循特定的目录结构,以便主程序能够正确加载和执行
plugin-example/
|-- index.html # 插件的 UI 入口文件
|-- preload.js # 预加载脚本,用于桥接主进程和插件进程
└── package.json # 插件的元数据描述文件package.json
package.json 文件定义插件的所有元数据,除了标准的 npm 字段外,还扩展了一些自定义字段以满足插件系统的需求
标准字段示例:
{
"name": "plugin-demo",
"version": "1.0.0",
"description": "这是一个插件的描述信息。",
"author": "Your Name",
"dependencies": {
// 插件的运行时依赖
}
}扩展字段详解:
{
// ...
"pluginName": "演示插件", // 插件的中文名称,用于在 UI 中展示
"main": "./index.html", // 插件的入口文件路径
"logo": "path/to/logo.png", // 插件的 Logo 图标
"pluginType": "ui", // 插件类型('ui' 或 'system')
"features": [
{
"explain": "启动演示插件", // 功能描述
"cmds": [
"demo", // 激活插件的关键词
"演示插件"
]
}
]
}pluginType:ui: 带有用户界面的插件,通过BrowserView加载system: 在后台运行的系统级插件,无界面
features: 定义了用户如何与插件交互。cmds数组中的关键词可以被主程序捕获,用于激活或调用此插件
index.html
这是插件的 UI 入口,主程序将通过 BrowserView 加载此文件
// 主程序中创建 BrowserView 的逻辑
import { BrowserView } from "electron"
const createView = (plugin, window) => {
const { indexPath, preload } = plugin
const view = new BrowserView({
webPreferences: {
preload // 指定预加载脚本
// ...
}
})
window.setBrowserView(view)
view.webContents.loadURL(indexPath) // 加载插件的 HTML 文件
}简单的 index.html 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>插件标题</title>
</head>
<body>
<h1>Hello, Plugin!</h1>
</body>
<script>
// 通过 preload 暴露的 API 与主程序或其他插件交互
window.rubick.showNotification("插件加载成功!")
window.pluginAPI.doSomething()
</script>
</html>preload.js
preload.js 是连接插件渲染进程和主进程的桥梁。由于渲染进程运行在沙箱环境中,出于安全考虑,它不能直接访问 Node.js 或 Electron 的 API。preload.js 在拥有 Node.js 环境访问权限的上下文中执行,可以通过 contextBridge 向插件的 window 对象安全地暴露特定 API。
// preload.js
import { contextBridge } from "electron"
const pluginAPI = {
doSomething() {
// 在这里可以安全地调用 Node.js 或 Electron 的 API
console.log("Action from plugin!")
}
}
// 将 API 暴露给插件的渲染进程
contextBridge.exposeInMainWorld("pluginAPI", pluginAPI)插件的生命周期管理
基于 npm 的插件管理,可以方便地实现插件的安装、更新和卸载
插件的安装
在 Electron 应用中执行 npm install 命令,最直接的方式是借助 Node.js 的 child_process 模块(或 cross-spawn 这样的跨平台库)来调用系统 Shell
方法一:使用 spawn 执行 npm 命令
import spawn from "cross-spawn"
async function execCommand(cmd, modules, baseDir, registry) {
return new Promise((resolve, reject) => {
const args = [
cmd,
...modules.map((m) => `${m}@latest`),
"--save",
`--registry=${registry}`
]
const npmProcess = spawn("npm", args, { cwd: baseDir })
let output = ""
npmProcess.stdout.on("data", (data) => (output += data.toString()))
npmProcess.stderr.on("data", (data) => (output += data.toString()))
npmProcess.on("close", (code) => {
if (code === 0) {
resolve({ success: true, data: output })
} else {
reject({ success: false, error: output })
}
})
})
}
// 安装 plugin-demo
// execCommand('install', ['plugin-demo'], '/path/to/plugins', 'https://registry.npm.taobao.org');问题:此方法依赖于用户系统中已安装 Node.js 和 npm,否则会因找不到 npm 命令而失败
方法二:编程式使用 npm
为了解决环境依赖问题,可以将 npm 作为一个库直接集成到 Electron 应用中,以编程方式调用其命令。
注意:自 npm v7 以来,官方不再推荐或支持编程式 API,其稳定性无法得到保证。因此选择一个相对稳定且支持该用法的旧版本,如 v6.14.7
import npm from "npm"
import path from "path"
async function execCommand(cmd, modules, baseDir, registry) {
return new Promise((resolve, reject) => {
const config = {
prefix: baseDir,
save: true,
cache: path.join(baseDir, "cache"),
registry: registry
}
npm.load(config, (loadErr) => {
if (loadErr) return reject({ success: false, error: loadErr })
npm.commands[cmd](modules, (cmdErr, data) => {
if (cmdErr) {
reject({ success: false, error: cmdErr })
} else {
resolve({ success: true, data })
}
})
})
})
}
// 安装 plugin-demo
// execCommand('install', ['plugin-demo@latest'], '/path/to/plugins', 'https://registry.npm.taobao.org');通过这种方式,插件安装不再依赖于用户的本地环境,大大提升了应用的健壮性
插件的更新
插件更新的本质是安装最新版本的 npm 包。实现逻辑如下:
- 检查新版本:对比本地安装的插件版本和 npm registry 上的最新版本。
- 执行更新:如果存在新版本,则执行
npm install <plugin-name>@latest命令
import axios from "axios"
import fs from "fs"
async function upgradePlugin(name, baseDir, registry) {
try {
// 1. 获取本地版本
const pkgPath = path.join(baseDir, "node_modules", name, "package.json")
const localPkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const localVersion = localPkg.version
// 2. 获取远程最新版本
const registryUrl = `${registry}/${name}`
const { data } = await axios.get(registryUrl)
const latestVersion = data["dist-tags"].latest
// 3. 对比版本并更新
if (latestVersion > localVersion) {
console.log(`发现新版本 ${latestVersion},正在更新...`)
await execCommand("install", [`${name}@latest`], baseDir, registry)
console.log("插件更新成功!")
} else {
console.log("已是最新版本。")
}
} catch (error) {
console.error("插件更新失败:", error)
}
}插件的卸载
卸载插件同样简单,只需调用 npm uninstall 命令即可
async function uninstallPlugin(name, baseDir) {
try {
await execCommand("uninstall", [name], baseDir)
console.log(`插件 ${name} 卸载成功!`)
} catch (error) {
console.error("插件卸载失败:", error)
}
}关于本地调试:
在开发阶段,可以使用 npm link 来调试本地插件。在卸载或安装时,需要区分当前是开发环境还是生产环境,以执行 unlink 或 uninstall
async function uninstall(pluginName, options) {
const command = options.isDev ? "unlink" : "uninstall"
await execCommand(command, [pluginName])
}完整的插件管理实现可以参考:rubick/src/core/plugin-handler/index.ts
插件安全校验机制
校验流程
┌─────────────────────────────────────────────────────────────────────┐
│ 插件安全校验流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 下载插件包 │ │
│ └───────┬──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 完整性校验 │───────────►│ 拒绝安装 │ │
│ │ (SHA256) │ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 签名验证 │───────────►│ 拒绝安装 │ │
│ │ (RSA/DSA) │ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 元数据校验 │───────────►│ 拒绝安装 │ │
│ │ (package.json)│ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ │
│ │ 安装插件 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘SHA256 完整性校验
// src/main/plugin/validator.ts
import crypto from 'crypto'
import fs from 'fs'
export async function verifyFileIntegrity(
filePath: string,
expectedHash: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (data) => hash.update(data))
stream.on('end', () => {
const actualHash = hash.digest('hex')
resolve(actualHash === expectedHash)
})
stream.on('error', reject)
})
}
// 使用示例
const isValid = await verifyFileIntegrity(
'/path/to/plugin.zip',
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
)
if (!isValid) {
throw new Error('插件文件完整性校验失败')
}签名验证
// src/main/plugin/signer.ts
import crypto from 'crypto'
export interface PluginSignature {
signature: string
publicKey: string
timestamp: number
}
export class PluginSigner {
private privateKey: string
private publicKey: string
constructor(privateKey: string, publicKey: string) {
this.privateKey = privateKey
this.publicKey = publicKey
}
// 签名插件
async signPlugin(pluginPath: string): Promise<PluginSignature> {
const content = await fs.promises.readFile(pluginPath)
const sign = crypto.createSign('RSA-SHA256')
sign.update(content)
sign.end()
return {
signature: sign.sign(this.privateKey, 'base64'),
publicKey: this.publicKey,
timestamp: Date.now()
}
}
// 验证签名
async verifySignature(
pluginPath: string,
signature: PluginSignature
): Promise<boolean> {
try {
const content = await fs.promises.readFile(pluginPath)
const verify = crypto.createVerify('RSA-SHA256')
verify.update(content)
verify.end()
return verify.verify(
signature.publicKey,
signature.signature,
'base64'
)
} catch (error) {
console.error('签名验证失败:', error)
return false
}
}
}元数据校验
// src/main/plugin/validator.ts
export interface PluginMetadata {
name: string
version: string
main: string
pluginName: string
pluginType: 'ui' | 'system'
}
export function validatePluginMetadata(
pkg: any
): { valid: boolean; errors: string[] } {
const errors: string[] = []
// 必需字段检查
const requiredFields = ['name', 'version', 'main', 'pluginName', 'pluginType']
for (const field of requiredFields) {
if (!pkg[field]) {
errors.push(`缺少必需字段: ${field}`)
}
}
// 名称格式检查
if (pkg.name && !/^[a-z0-9-]+$/.test(pkg.name)) {
errors.push('插件名称只能包含小写字母、数字和连字符')
}
// 版本格式检查
if (pkg.version && !/^\d+\.\d+\.\d+/.test(pkg.version)) {
errors.push('版本号格式不正确,应为 semver 格式')
}
// 插件类型检查
if (pkg.pluginType && !['ui', 'system'].includes(pkg.pluginType)) {
errors.push('插件类型必须为 ui 或 system')
}
return {
valid: errors.length === 0,
errors
}
}权限声明与检查
// package.json 中声明权限
{
"name": "my-plugin",
"permissions": [
"file:read",
"file:write",
"clipboard:read",
"notification:show"
]
}// src/main/plugin/permission.ts
export class PermissionManager {
private allowedPermissions = new Map<string, string[]>()
// 定义权限等级
private permissionLevels = {
safe: ['notification:show', 'clipboard:read'],
moderate: ['file:read', 'file:write', 'dialog:open'],
dangerous: ['shell:openExternal', 'process:spawn']
}
// 注册插件权限
registerPlugin(pluginName: string, permissions: string[]) {
this.allowedPermissions.set(pluginName, permissions)
}
// 检查权限
hasPermission(pluginName: string, permission: string): boolean {
const allowed = this.allowedPermissions.get(pluginName) || []
return allowed.includes(permission)
}
// 获取权限等级
getPermissionLevel(permission: string): string {
for (const [level, perms] of Object.entries(this.permissionLevels)) {
if (perms.includes(permission)) {
return level
}
}
return 'unknown'
}
}插件调试指南
开发环境调试
// src/main/plugin/debug.ts
import { BrowserView } from 'electron'
export function setupPluginDebug(view: BrowserView, pluginName: string) {
// 自动打开 DevTools
if (process.env.NODE_ENV === 'development') {
view.webContents.openDevTools({ mode: 'detach' })
}
// 监听控制台消息
view.webContents.on('console-message', (event, level, message, line, sourceId) => {
const logLevel = ['verbose', 'info', 'warning', 'error'][level]
console.log(`[${pluginName}] [${logLevel}] ${message}`)
})
// 监听加载事件
view.webContents.on('did-finish-load', () => {
console.log(`[${pluginName}] 加载完成`)
})
view.webContents.on('did-fail-load', (event, code, desc) => {
console.error(`[${pluginName}] 加载失败: ${code} - ${desc}`)
})
}远程调试
// 启用远程调试端口
app.commandLine.appendSwitch('remote-debugging-port', '9222')
// 然后可以通过 chrome://inspect 访问插件日志记录
// src/main/plugin/logger.ts
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
export class PluginLogger {
private logDir: string
constructor() {
this.logDir = path.join(app.getPath('userData'), 'plugin-logs')
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true })
}
}
log(pluginName: string, level: string, message: string, data?: any) {
const timestamp = new Date().toISOString()
const logEntry = {
timestamp,
plugin: pluginName,
level,
message,
data
}
const logFile = path.join(this.logDir, `${pluginName}.log`)
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n')
// 同时输出到控制台
console.log(`[${timestamp}] [${pluginName}] [${level}] ${message}`)
}
info(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'INFO', message, data)
}
error(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'ERROR', message, data)
}
warn(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'WARN', message, data)
}
}性能监控
// src/main/plugin/monitor.ts
export class PluginMonitor {
private metrics = new Map<string, any>()
startMonitoring(pluginName: string, view: BrowserView) {
// 定期收集性能指标
const interval = setInterval(async () => {
try {
const memoryInfo = await view.webContents.getProcessMemoryInfo()
const cpuUsage = process.cpuUsage()
this.metrics.set(pluginName, {
memory: memoryInfo,
cpu: cpuUsage,
timestamp: Date.now()
})
// 内存预警
if (memoryInfo.privateBytes > 200 * 1024 * 1024) {
console.warn(`[${pluginName}] 内存使用超过 200MB`)
}
} catch (error) {
console.error(`[${pluginName}] 监控失败:`, error)
}
}, 10000) // 每 10 秒采集一次
return () => clearInterval(interval)
}
getMetrics(pluginName: string) {
return this.metrics.get(pluginName)
}
}多窗口插件管理
// src/main/plugin/multiWindow.ts
import { BrowserWindow, BrowserView } from 'electron'
export class MultiWindowPluginManager {
private windowPlugins = new Map<number, Map<string, BrowserView>>()
// 在指定窗口加载插件
loadPluginInWindow(
windowId: number,
pluginName: string,
pluginPath: string
): BrowserView {
const win = BrowserWindow.fromId(windowId)
if (!win) {
throw new Error(`Window ${windowId} not found`)
}
// 检查该窗口是否已有此插件
let windowPlugins = this.windowPlugins.get(windowId)
if (!windowPlugins) {
windowPlugins = new Map()
this.windowPlugins.set(windowId, windowPlugins)
}
if (windowPlugins.has(pluginName)) {
return windowPlugins.get(pluginName)!
}
// 创建新的 BrowserView
const view = new BrowserView({
webPreferences: {
preload: path.join(pluginPath, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
win.addBrowserView(view)
view.webContents.loadFile(path.join(pluginPath, 'index.html'))
windowPlugins.set(pluginName, view)
return view
}
// 卸载窗口中的插件
unloadPluginFromWindow(windowId: number, pluginName: string) {
const windowPlugins = this.windowPlugins.get(windowId)
if (!windowPlugins) return
const view = windowPlugins.get(pluginName)
if (view) {
const win = BrowserWindow.fromId(windowId)
win?.removeBrowserView(view)
view.webContents.destroy()
windowPlugins.delete(pluginName)
}
}
// 获取窗口中的所有插件
getWindowPlugins(windowId: number): string[] {
return Array.from(this.windowPlugins.get(windowId)?.keys() || [])
}
}常见问题解答
Q1: 插件安装后无法加载?
排查步骤:
- 检查插件目录结构是否完整
- 验证
package.json格式是否正确 - 检查
main字段指向的文件是否存在 - 查看主进程日志中的错误信息
// 添加详细日志
ipcMain.on('plugin:load-error', (event, error) => {
console.error('插件加载错误:', error)
logger.error('Plugin load error:', error)
})Q2: 如何处理插件依赖冲突?
// 使用 npm 的 peerDependencies 声明兼容版本
{
"peerDependencies": {
"vue": "^3.0.0",
"react": "^18.0.0"
},
"peerDependenciesMeta": {
"vue": { "optional": true },
"react": { "optional": true }
}
}
// 主应用检查依赖兼容性
async function checkDependencies(plugin) {
const dependencies = plugin.dependencies || {}
const incompatible = []
for (const [dep, version] of Object.entries(dependencies)) {
const installed = await getInstalledVersion(dep)
if (installed && !semver.satisfies(installed, version)) {
incompatible.push({ dep, required: version, installed })
}
}
return incompatible
}Q3: 如何实现插件热更新?
// 监听插件文件变化
import chokidar from 'chokidar'
export class PluginHotReload {
private watchers = new Map<string, FSWatcher>()
watch(pluginPath: string, onChange: () => void) {
const watcher = chokidar.watch(pluginPath, {
ignored: /node_modules/,
ignoreInitial: true
})
watcher.on('change', (filePath) => {
console.log(`文件变化: ${filePath}`)
onChange()
})
this.watchers.set(pluginPath, watcher)
}
unwatch(pluginPath: string) {
const watcher = this.watchers.get(pluginPath)
if (watcher) {
watcher.close()
this.watchers.delete(pluginPath)
}
}
}Q4: 如何限制插件的网络访问?
// 使用 session 隔离网络请求
import { session } from 'electron'
function createPluginSession(pluginName: string) {
const ses = session.fromPartition(`persist:plugin-${pluginName}`)
// 只允许特定域名
ses.webRequest.onBeforeRequest((details, callback) => {
const allowedDomains = ['api.example.com', 'cdn.example.com']
const url = new URL(details.url)
if (allowedDomains.includes(url.hostname)) {
callback({ cancel: false })
} else {
console.warn(`[${pluginName}] 阻止访问: ${url.hostname}`)
callback({ cancel: true })
}
})
return ses
}Q5: 如何处理插件异常退出?
// 插件健康检查与自动恢复
export class PluginHealthChecker {
private healthStatus = new Map<string, boolean>()
startChecking(pluginName: string, view: BrowserView) {
const interval = setInterval(async () => {
try {
// 发送心跳检测
const response = await view.webContents.executeJavaScript(
'typeof window.__pluginHealthCheck === "function" ? window.__pluginHealthCheck() : true'
)
this.healthStatus.set(pluginName, response)
} catch (error) {
console.warn(`[${pluginName}] 健康检查失败`)
this.healthStatus.set(pluginName, false)
// 自动重启插件
this.restartPlugin(pluginName, view)
}
}, 30000) // 每 30 秒检查一次
return () => clearInterval(interval)
}
async restartPlugin(pluginName: string, view: BrowserView) {
console.log(`[${pluginName}] 正在重启...`)
view.webContents.reload()
}
}版本兼容性
Electron 与 Node.js 版本对应
| Electron 版本 | Node.js 版本 | Chromium 版本 |
|---|---|---|
| 28.x | 18.17.1 | 120.0.6099.56 |
| 27.x | 18.17.1 | 118.0.5993.54 |
| 26.x | 18.16.0 | 116.0.5845.97 |
| 25.x | 18.15.0 | 114.0.5735.134 |
npm 版本建议
- 编程式 npm API:推荐使用
npm v6.x,因v7+不再官方支持编程式调用 - spawn 方式:可使用任意版本,但依赖用户环境
// package.json 中锁定 npm 版本
{
"dependencies": {
"npm": "6.14.18"
}
}兼容性检查工具
// src/main/plugin/compatibility.ts
import { app } from 'electron'
import semver from 'semver'
export interface CompatibilityResult {
compatible: boolean
issues: string[]
warnings: string[]
}
export function checkPluginCompatibility(plugin: any): CompatibilityResult {
const issues: string[] = []
const warnings: string[] = []
// 检查 Node.js 版本要求
if (plugin.engines?.node) {
const nodeVersion = process.versions.node
if (!semver.satisfies(nodeVersion, plugin.engines.node)) {
issues.push(
`Node.js 版本不兼容: 要求 ${plugin.engines.node}, 当前 ${nodeVersion}`
)
}
}
// 检查 Electron 版本要求
if (plugin.engines?.electron) {
const electronVersion = process.versions.electron
if (!semver.satisfies(electronVersion, plugin.engines.electron)) {
issues.push(
`Electron 版本不兼容: 要求 ${plugin.engines.electron}, 当前 ${electronVersion}`
)
}
}
// 检查 API 版本
if (plugin.apiVersion && plugin.apiVersion !== CURRENT_API_VERSION) {
warnings.push(
`API 版本不匹配: 插件 ${plugin.apiVersion}, 主程序 ${CURRENT_API_VERSION}`
)
}
return {
compatible: issues.length === 0,
issues,
warnings
}
}