diff --git a/electron/main.ts b/electron/main.ts index 13ed5e4..2caf3c9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -87,6 +87,11 @@ const keyService = new KeyService() let mainWindowReady = false let shouldShowMain = true +// 更新下载状态管理(Issue #294 修复) +let isDownloadInProgress = false +let downloadProgressHandler: ((progress: any) => void) | null = null +let downloadedHandler: (() => void) | null = null + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -617,22 +622,61 @@ function registerIpcHandlers() { if (!AUTO_UPDATE_ENABLED) { throw new Error('自动更新已暂时禁用') } + + // 防止重复下载(Issue #294 修复) + if (isDownloadInProgress) { + throw new Error('更新正在下载中,请稍候') + } + + isDownloadInProgress = true const win = BrowserWindow.fromWebContents(event.sender) - // 监听下载进度 - autoUpdater.on('download-progress', (progress) => { - win?.webContents.send('app:downloadProgress', progress) - }) + // 清理旧的监听器(Issue #294 修复:防止监听器泄漏) + if (downloadProgressHandler) { + autoUpdater.removeListener('download-progress', downloadProgressHandler) + downloadProgressHandler = null + } + if (downloadedHandler) { + autoUpdater.removeListener('update-downloaded', downloadedHandler) + downloadedHandler = null + } - // 下载完成后自动安装 - autoUpdater.on('update-downloaded', () => { + // 创建新的监听器并保存引用 + downloadProgressHandler = (progress) => { + if (win && !win.isDestroyed()) { + win.webContents.send('app:downloadProgress', progress) + } + } + + downloadedHandler = () => { + console.log('[Update] 更新下载完成,准备安装') + if (downloadProgressHandler) { + autoUpdater.removeListener('download-progress', downloadProgressHandler) + downloadProgressHandler = null + } + downloadedHandler = null + isDownloadInProgress = false autoUpdater.quitAndInstall(false, true) - }) + } + + autoUpdater.on('download-progress', downloadProgressHandler) + autoUpdater.once('update-downloaded', downloadedHandler) try { + console.log('[Update] 开始下载更新...') await autoUpdater.downloadUpdate() } catch (error) { - console.error('下载更新失败:', error) + console.error('[Update] 下载更新失败:', error) + // 失败时清理状态和监听器 + isDownloadInProgress = false + if (downloadProgressHandler) { + autoUpdater.removeListener('download-progress', downloadProgressHandler) + downloadProgressHandler = null + } + if (downloadedHandler) { + autoUpdater.removeListener('update-downloaded', downloadedHandler) + downloadedHandler = null + } throw error } }) diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 80a7169..001a855 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -79,14 +79,14 @@ class AnalyticsService { const chunkSize = 200 for (let i = 0; i < usernames.length; i += chunkSize) { const chunk = usernames.slice(i, i + chunkSize) - const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') - if (!inList) continue + // 使用参数化查询防止SQL注入 + const placeholders = chunk.map(() => '?').join(',') const sql = ` SELECT username, alias FROM contact - WHERE username IN (${inList}) + WHERE username IN (${placeholders}) ` - const result = await wcdbService.execQuery('contact', null, sql) + const result = await wcdbService.execQuery('contact', null, sql, chunk) if (!result.success || !result.rows) continue for (const row of result.rows as Record[]) { const username = row.username || '' diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index c36d6ff..571fbad 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { voiceTranscribeService } from './voiceTranscribeService' +import { LRUCache } from '../utils/LRUCache.js' type HardlinkState = { db: Database.Database @@ -114,6 +115,7 @@ class ChatService { private configService: ConfigService private connected = false private messageCursors: Map = new Map() + private messageCursorMutex: boolean = false private readonly messageBatchDefault = 50 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -121,8 +123,8 @@ class ChatService { private hardlinkCache = new Map() private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService - private voiceWavCache = new Map() - private voiceTranscriptCache = new Map() + private voiceWavCache: LRUCache + private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() private transcriptCacheLoaded = false private transcriptCacheDirty = false @@ -149,6 +151,9 @@ class ChatService { const persisted = this.contactCacheService.getAllEntries() this.avatarCache = new Map(Object.entries(persisted)) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) + // 初始化LRU缓存,限制大小防止内存泄漏 + this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) + this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 } /** @@ -728,8 +733,15 @@ class ChatService { } const batchSize = Math.max(1, limit || this.messageBatchDefault) + + // 使用互斥锁保护游标状态访问 + while (this.messageCursorMutex) { + await new Promise(resolve => setTimeout(resolve, 1)) + } + this.messageCursorMutex = true + let state = this.messageCursors.get(sessionId) - + // 只在以下情况重新创建游标: // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) @@ -765,7 +777,8 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) - + this.messageCursorMutex = false + // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 @@ -866,9 +879,12 @@ class ChatService { } state.fetched += rows.length + this.messageCursorMutex = false + this.messageCacheService.set(sessionId, normalized) return { success: true, messages: normalized, hasMore } } catch (e) { + this.messageCursorMutex = false console.error('ChatService: 获取消息失败:', e) return { success: false, error: String(e) } } @@ -3698,10 +3714,7 @@ class ChatService { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { this.voiceWavCache.set(cacheKey, wavData) - if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) { - const oldestKey = this.voiceWavCache.keys().next().value - if (oldestKey) this.voiceWavCache.delete(oldestKey) - } + // LRU缓存会自动处理大小限制,无需手动清理 } /** 获取持久化转写缓存文件路径 */ diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 8d4fbf5..920b226 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -12,6 +12,7 @@ import { chatService } from './chatService' import { videoService } from './videoService' import { voiceTranscribeService } from './voiceTranscribeService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' +import { LRUCache } from '../utils/LRUCache.js' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -140,12 +141,15 @@ async function parallelLimit( class ExportService { private configService: ConfigService - private contactCache: Map = new Map() - private inlineEmojiCache: Map = new Map() + private contactCache: LRUCache + private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null constructor() { this.configService = new ConfigService() + // 限制缓存大小,防止内存泄漏 + this.contactCache = new LRUCache(500) // 最多缓存500个联系人 + this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情 } private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { @@ -219,9 +223,9 @@ class ExportService { */ async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { try { - const escapedChatroomId = chatroomId.replace(/'/g, "''") - const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` - const result = await wcdbService.execQuery('contact', null, sql) + // 使用参数化查询防止SQL注入 + const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' + const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) if (!result.success || !result.rows || result.rows.length === 0) { return new Map() } @@ -1467,6 +1471,7 @@ class ExportService { }) if (!result.success || !result.localPath) { + console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) // 尝试获取缩略图 const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, @@ -1474,8 +1479,10 @@ class ExportService { imageDatName }) if (!thumbResult.success || !thumbResult.localPath) { + console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`) return null } + console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) result.localPath = thumbResult.localPath } @@ -1503,7 +1510,10 @@ class ExportService { } // 复制文件 - if (!fs.existsSync(sourcePath)) return null + if (!fs.existsSync(sourcePath)) { + console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) + return null + } const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) @@ -1517,6 +1527,7 @@ class ExportService { kind: 'image' } } catch (e) { + console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`) return null } } @@ -1785,7 +1796,14 @@ class ExportService { fileStream.close() resolve(true) }) - fileStream.on('error', () => { + fileStream.on('error', (err) => { + // 确保在错误情况下销毁流,释放文件句柄 + fileStream.destroy() + resolve(false) + }) + response.on('error', (err) => { + // 确保在响应错误时也关闭文件句柄 + fileStream.destroy() resolve(false) }) }) @@ -1812,22 +1830,43 @@ class ExportService { let firstTime: number | null = null let lastTime: number | null = null + // 修复时间范围:0 表示不限制,而不是时间戳 0 + const beginTime = dateRange?.start || 0 + const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0 + + console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`) + const cursor = await wcdbService.openMessageCursor( sessionId, 500, true, - dateRange?.start || 0, - dateRange?.end || 0 + beginTime, + endTime ) if (!cursor.success || !cursor.cursor) { + console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`) return { rows, memberSet, firstTime, lastTime } } try { let hasMore = true + let batchCount = 0 while (hasMore) { const batch = await wcdbService.fetchMessageBatch(cursor.cursor) - if (!batch.success || !batch.rows) break + batchCount++ + + if (!batch.success) { + console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`) + break + } + + if (!batch.rows) { + console.warn(`[Export] 批次 ${batchCount} 无数据`) + break + } + + console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`) + for (const row of batch.rows) { const createTime = parseInt(row.create_time || '0', 10) if (dateRange) { @@ -1918,8 +1957,17 @@ class ExportService { } hasMore = batch.hasMore === true } + + console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`) + } catch (err) { + console.error(`[Export] 收集消息异常:`, err) } finally { - await wcdbService.closeMessageCursor(cursor.cursor) + try { + await wcdbService.closeMessageCursor(cursor.cursor) + console.log(`[Export] 游标已关闭`) + } catch (err) { + console.error(`[Export] 关闭游标失败:`, err) + } } if (senderSet.size > 0) { @@ -4562,6 +4610,12 @@ class ExportService { `); return new Promise((resolve, reject) => { + stream.on('error', (err) => { + // 确保在流错误时销毁流,释放文件句柄 + stream.destroy() + reject(err) + }) + stream.end(() => { onProgress?.({ current: 100, diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index bab754d..b6168b6 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -100,6 +100,7 @@ class HttpService { private port: number = 5031 private running: boolean = false private connections: Set = new Set() + private connectionMutex: boolean = false constructor() { this.configService = ConfigService.getInstance() @@ -120,9 +121,20 @@ class HttpService { // 跟踪所有连接,以便关闭时能强制断开 this.server.on('connection', (socket) => { - this.connections.add(socket) + // 使用互斥锁防止并发修改 + if (!this.connectionMutex) { + this.connectionMutex = true + this.connections.add(socket) + this.connectionMutex = false + } + socket.on('close', () => { - this.connections.delete(socket) + // 使用互斥锁防止并发修改 + if (!this.connectionMutex) { + this.connectionMutex = true + this.connections.delete(socket) + this.connectionMutex = false + } }) }) @@ -150,11 +162,20 @@ class HttpService { async stop(): Promise { return new Promise((resolve) => { if (this.server) { - // 强制关闭所有活动连接 - for (const socket of this.connections) { - socket.destroy() - } + // 使用互斥锁保护连接集合操作 + this.connectionMutex = true + const socketsToClose = Array.from(this.connections) this.connections.clear() + this.connectionMutex = false + + // 强制关闭所有活动连接 + for (const socket of socketsToClose) { + try { + socket.destroy() + } catch (err) { + console.error('[HttpService] Error destroying socket:', err) + } + } this.server.close(() => { this.running = false diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index ee9b191..5ff3d84 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -458,8 +458,18 @@ export class VoiceTranscribeService { writer.on('error', (err) => { clearInterval(speedInterval) + // 确保在错误情况下也关闭文件句柄 + writer.destroy() reject(err) }) + + response.on('error', (err) => { + clearInterval(speedInterval) + // 确保在响应错误时也关闭文件句柄 + writer.destroy() + reject(err) + }) + response.pipe(writer) }) request.on('error', reject) diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 25912f1..da1037d 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -361,10 +361,10 @@ export class WcdbService { } /** - * 执行 SQL 查询 + * 执行 SQL 查询(支持参数化查询) */ - async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { - return this.callWorker('execQuery', { kind, path, sql }) + async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('execQuery', { kind, path, sql, params }) } /** diff --git a/electron/utils/LRUCache.ts b/electron/utils/LRUCache.ts new file mode 100644 index 0000000..b246a7c --- /dev/null +++ b/electron/utils/LRUCache.ts @@ -0,0 +1,93 @@ +/** + * LRU (Least Recently Used) Cache implementation for memory management + */ +export class LRUCache { + private cache: Map + private maxSize: number + + constructor(maxSize: number = 100) { + this.maxSize = maxSize + this.cache = new Map() + } + + /** + * Get value from cache + */ + get(key: K): V | undefined { + const value = this.cache.get(key) + if (value !== undefined) { + // Move to end (most recently used) + this.cache.delete(key) + this.cache.set(key, value) + } + return value + } + + /** + * Set value in cache + */ + set(key: K, value: V): void { + if (this.cache.has(key)) { + // Update existing + this.cache.delete(key) + } else if (this.cache.size >= this.maxSize) { + // Remove least recently used (first item) + const firstKey = this.cache.keys().next().value + if (firstKey !== undefined) { + this.cache.delete(firstKey) + } + } + this.cache.set(key, value) + } + + /** + * Check if key exists + */ + has(key: K): boolean { + return this.cache.has(key) + } + + /** + * Delete key from cache + */ + delete(key: K): boolean { + return this.cache.delete(key) + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear() + } + + /** + * Get current cache size + */ + get size(): number { + return this.cache.size + } + + /** + * Get all keys (for debugging) + */ + keys(): IterableIterator { + return this.cache.keys() + } + + /** + * Force cleanup (optional method for explicit memory management) + */ + cleanup(): void { + // In JavaScript/TypeScript, this is mainly for consistency + // The garbage collector will handle actual memory cleanup + if (this.cache.size > this.maxSize * 1.5) { + // Emergency cleanup if cache somehow exceeds limit + const entries = Array.from(this.cache.entries()) + this.cache.clear() + // Keep only the most recent half + const keepEntries = entries.slice(-Math.floor(this.maxSize / 2)) + keepEntries.forEach(([key, value]) => this.cache.set(key, value)) + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 854d3f6..4c688ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2909,6 +2910,7 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3055,6 +3057,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3994,6 +3997,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5103,6 +5107,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5290,6 +5295,7 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5376,7 +5382,6 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5390,7 +5395,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5406,7 +5410,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5420,7 +5423,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9150,6 +9152,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9159,6 +9162,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9593,6 +9597,7 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10437,6 +10442,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10884,6 +10890,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10973,7 +10980,8 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -10999,6 +11007,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" },