diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4c7459..c46b41b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,20 +8,75 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: + release-mac-arm64: + runs-on: macos-14 + + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm ci + + - name: Sync version with tag + shell: bash + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "Syncing package.json version to $VERSION" + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish macOS arm64 (unsigned DMG) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + npx electron-builder --mac dmg --arm64 --publish always + + - name: Update Release Notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + cat < release_notes.md + ## 更新日志 + 修复了一些已知问题 + + ## 查看更多日志/获取最新动态 + [点击加入 Telegram 频道](https://t.me/weflow_cc) + EOF + + gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md + release: runs-on: windows-latest steps: - name: Check out git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: 22.12 + node-version: 24 cache: 'npm' - name: Install Dependencies diff --git a/.gitignore b/.gitignore index 3458a1c..0623e78 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ chatlab-format.md *.bak AGENTS.md .claude/ +CLAUDE.md .agents/ resources/wx_send 概述.md diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 56826a2..429a00f 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -10,7 +10,7 @@ type WorkerPayload = { thumbOnly: boolean } -type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean } +type Candidate = { score: number; path: string; isThumb: boolean } const payload = workerData as WorkerPayload @@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean { return /^[a-fA-F0-9]{16,32}$/.test(value) } +function stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower +} + function hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) -} - -function isLikelyImageDatBase(baseLower: string): boolean { - return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function normalizeDatBase(name: string): string { @@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + while (true) { + const stripped = stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base +} + +function isLikelyImageDatBase(baseLower: string): boolean { + return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower)) } function matchesDatName(fileName: string, datName: string): boolean { @@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean { const normalizedBase = normalizeDatBase(base) const normalizedTarget = normalizeDatBase(datName.toLowerCase()) if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`) - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) + return lower.endsWith('.dat') && lower.includes(normalizedTarget) } function scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 + const lower = fileName.toLowerCase() + const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (isThumbnailDat(lower)) return 100 + return 350 } function isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') -} - -function isHdDat(fileName: string): boolean { const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - return base.endsWith('_hd') || base.endsWith('_h') + return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat') } function walkForDat( @@ -105,20 +120,15 @@ function walkForDat( if (!lower.endsWith('.dat')) continue const baseLower = lower.slice(0, -4) if (!isLikelyImageDatBase(baseLower)) continue - if (!hasXVariant(baseLower)) continue if (!matchesDatName(lower, datName)) continue - // 排除高清图片格式 (_hd, _h) - if (isHdDat(lower)) continue matchedBases.add(baseLower) const isThumb = isThumbnailDat(lower) if (!allowThumbnail && isThumb) continue if (thumbOnly && !isThumb) continue - const score = scoreDatName(lower) candidates.push({ - score, + score: scoreDatName(lower), path: entryPath, - isThumb, - hasX: hasXVariant(baseLower) + isThumb }) } } @@ -126,10 +136,8 @@ function walkForDat( return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) } } - const withX = candidates.filter((item) => item.hasX) - const basePool = withX.length ? withX : candidates - const nonThumb = basePool.filter((item) => !item.isThumb) - const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool) + const nonThumb = candidates.filter((item) => !item.isThumb) + const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates) let best: { score: number; path: string } | null = null for (const item of finalPool) { diff --git a/electron/main.ts b/electron/main.ts index ddac8ec..d6f971b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -235,13 +235,32 @@ const isYearsLoadCanceled = (taskId: string): boolean => { return task?.canceled === true } +const setupCustomTitleBarWindow = (win: BrowserWindow): void => { + if (process.platform === 'darwin') { + win.setWindowButtonVisibility(false) + } + + const emitMaximizeState = () => { + if (win.isDestroyed()) return + win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen()) + } + + win.on('maximize', emitMaximizeState) + win.on('unmaximize', emitMaximizeState) + win.on('enter-full-screen', emitMaximizeState) + win.on('leave-full-screen', emitMaximizeState) + win.webContents.on('did-finish-load', emitMaximizeState) +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const win = new BrowserWindow({ width: 1400, @@ -256,13 +275,10 @@ function createWindow(options: { autoShow?: boolean } = {}) { webSecurity: false // Allow loading local files (video playback) }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: '#1a1a1a', - height: 40 - }, + titleBarOverlay: false, show: false }) + setupCustomTitleBarWindow(win) // 窗口准备好后显示 // Splash 模式下不在这里 show,由启动流程统一控制 @@ -367,7 +383,9 @@ function createAgreementWindow() { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const isDark = nativeTheme.shouldUseDarkColors @@ -417,7 +435,9 @@ function createSplashWindow(): BrowserWindow { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) splashWindow = new BrowserWindow({ width: 760, @@ -488,7 +508,9 @@ function createOnboardingWindow() { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) onboardingWindow = new BrowserWindow({ width: 960, @@ -534,7 +556,9 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) // 获取屏幕尺寸 const { screen } = require('electron') @@ -632,7 +656,9 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const win = new BrowserWindow({ width: 900, @@ -646,17 +672,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { nodeIntegration: false, webSecurity: false // 允许加载本地文件 }, - titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: '#ffffff', - height: 40 - }, + frame: false, show: false, backgroundColor: '#000000', autoHideMenuBar: true }) + setupCustomTitleBarWindow(win) + win.once('ready-to-show', () => { win.show() }) @@ -693,7 +716,9 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) // 根据系统主题设置窗口背景色 const isDark = nativeTheme.shouldUseDarkColors @@ -710,15 +735,12 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { nodeIntegration: false }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: isDark ? '#ffffff' : '#1a1a1a', - height: 32 - }, + titleBarOverlay: false, show: false, backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', autoHideMenuBar: true }) + setupCustomTitleBarWindow(win) win.once('ready-to-show', () => { win.show() @@ -771,7 +793,9 @@ function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWin const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const isDark = nativeTheme.shouldUseDarkColors @@ -964,6 +988,17 @@ function registerIpcHandlers() { } }) + ipcMain.handle('log:clear', async () => { + try { + const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log') + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, '', 'utf8') + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { return exportCardDiagnosticsService.snapshot(options?.limit) }) @@ -1103,6 +1138,11 @@ function registerIpcHandlers() { } }) + ipcMain.handle('window:isMaximized', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + return Boolean(win?.isMaximized() || win?.isFullScreen()) + }) + ipcMain.on('window:close', (event) => { BrowserWindow.fromWebContents(event.sender)?.close() }) @@ -2002,7 +2042,6 @@ function registerIpcHandlers() { dbPath, decryptKey, wxid, - nativeTimeoutMs: 5000, onProgress: (progress) => { if (isYearsLoadCanceled(taskId)) return const snapshot = updateTaskSnapshot({ diff --git a/electron/preload.ts b/electron/preload.ts index 41039e2..2dcc561 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', { log: { getPath: () => ipcRenderer.invoke('log:getPath'), read: () => ipcRenderer.invoke('log:read'), + clear: () => ipcRenderer.invoke('log:clear'), debug: (data: any) => ipcRenderer.send('log:debug', data) }, @@ -86,6 +87,12 @@ contextBridge.exposeInMainWorld('electronAPI', { window: { minimize: () => ipcRenderer.send('window:minimize'), maximize: () => ipcRenderer.send('window:maximize'), + isMaximized: () => ipcRenderer.invoke('window:isMaximized'), + onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => { + const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized) + ipcRenderer.on('window:maximizeStateChanged', listener) + return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) + }, close: () => ipcRenderer.send('window:close'), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 470933a..0b81fe2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -359,8 +359,9 @@ class ChatService { // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { this.handleSessionStatsMonitorChange(type, json) + const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 - BrowserWindow.getAllWindows().forEach((win) => { + windows.forEach((win) => { if (!win.isDestroyed()) { win.webContents.send('wcdb-change', { type, json }) } @@ -2974,7 +2975,9 @@ class ChatService { const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null + const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(content) + || null const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) if (senderUsername && (myWxidLower || cleanedWxidLower)) { @@ -4385,7 +4388,18 @@ class ChatService { } private stripSenderPrefix(content: string): string { - return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '') + return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '') + } + + private extractSenderUsernameFromContent(content: string): string | null { + if (!content) return null + + const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content))) + const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(normalized) + if (!match?.[1]) return null + + const candidate = match[1].trim() + return candidate || null } private decodeHtmlEntities(content: string): string { @@ -6594,7 +6608,9 @@ class ChatService { createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0), sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)), isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), - senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, + senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(rawContent) + || null, rawContent: rawContent, content: rawContent, // 添加原始内容供视频MD5解析使用 parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index 89edc29..c611bf0 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -67,7 +67,10 @@ class CloudControlService { } if (platform === 'darwin') { - return `macOS ${os.release()}` + // `os.release()` returns Darwin kernel version (e.g. 25.3.0), + // while cloud reporting expects the macOS product version (e.g. 26.3). + const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release() + return `macOS ${macVersion}` } return platform @@ -92,4 +95,3 @@ class CloudControlService { export const cloudControlService = new CloudControlService() - diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 20864a5..dcf3956 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -107,6 +107,15 @@ interface MediaExportItem { posterDataUrl?: string } +interface ExportDisplayProfile { + wxid: string + nickname: string + remark: string + alias: string + groupNickname: string + displayName: string +} + type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' @@ -860,6 +869,50 @@ class ExportService { } } + private async resolveExportDisplayProfile( + wxid: string, + preference: ExportOptions['displayNamePreference'], + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>, + groupNicknamesMap: Map, + fallbackDisplayName = '', + extraGroupNicknameCandidates: Array = [] + ): Promise { + const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown' + const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const } + const contact = contactResult.success ? contactResult.contact : null + const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid) + const remark = String(contact?.remark || '') + const alias = String(contact?.alias || '') + const groupNickname = this.resolveGroupNicknameByCandidates( + groupNicknamesMap, + [ + resolvedWxid, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias, + ...extraGroupNicknameCandidates + ] + ) || '' + const displayName = this.getPreferredDisplayName( + resolvedWxid, + nickname, + remark, + groupNickname, + preference || 'remark' + ) + + return { + wxid: resolvedWxid, + nickname, + remark, + alias, + groupNickname, + displayName + } + } + /** * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 * @param content 原始消息内容 XML @@ -2157,12 +2210,22 @@ class ExportService { imageMd5, imageDatName }) - if (!thumbResult.success || !thumbResult.localPath) { - console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`) - return null + if (thumbResult.success && thumbResult.localPath) { + console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) + result.localPath = thumbResult.localPath + } else { + console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`) + // 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL + const { imageStore } = await import('../main') + const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName) + if (cachedThumb) { + console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`) + result.localPath = cachedThumb + } else { + console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) + return null + } } - console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) - result.localPath = thumbResult.localPath } // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 @@ -3272,8 +3335,19 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } onProgress?.({ current: 0, @@ -3309,6 +3383,18 @@ class ExportService { await this.ensureVoiceModel(onProgress) } + const senderUsernames = new Set() + let senderScanIndex = 0 + for (const msg of allMessages) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + if (msg.senderUsername) senderUsernames.add(msg.senderUsername) + } + senderUsernames.add(sessionId) + senderUsernames.add(cleanedMyWxid) + await this.preloadContacts(senderUsernames, contactCache) + if (isGroup) { this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) @@ -3439,6 +3525,7 @@ class ExportService { }) const chatLabMessages: ChatLabMessage[] = [] + const senderProfileMap = new Map() let messageIndex = 0 for (const msg of allMessages) { if ((messageIndex++ & 0x7f) === 0) { @@ -3454,12 +3541,36 @@ class ExportService { const groupNickname = memberInfo.groupNickname || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '') || '' + const senderProfile = isGroup + ? await this.resolveExportDisplayProfile( + msg.senderUsername || cleanedMyWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + : { + wxid: msg.senderUsername || cleanedMyWxid, + nickname: memberInfo.accountName || msg.senderUsername || '', + remark: '', + alias: '', + groupNickname, + displayName: memberInfo.accountName || msg.senderUsername || '' + } + if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) { + senderProfileMap.set(senderProfile.wxid, senderProfile) + } // 确定消息内容 let content: string | null + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else if (mediaItem && msg.localType === 3) { + content = mediaItem.relativePath } else { content = this.parseMessageContent( msg.content, @@ -3490,8 +3601,8 @@ class ExportService { const message: ChatLabMessage = { sender: msg.senderUsername, - accountName: memberInfo.accountName, - groupNickname: groupNickname || undefined, + accountName: senderProfile.displayName || memberInfo.accountName, + groupNickname: (senderProfile.groupNickname || groupNickname) || undefined, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: content @@ -3607,10 +3718,27 @@ class ExportService { : new Map() const sessionAvatar = avatarMap.get(sessionId) - const members = Array.from(collected.memberSet.values()).map((info) => { + const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => { + const profile = isGroup + ? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile( + info.member.platformId, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + info.member.accountName || info.member.platformId, + this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : [] + )) + : null + const member = profile + ? { + ...info.member, + accountName: profile.displayName || info.member.accountName, + groupNickname: profile.groupNickname || info.member.groupNickname + } + : info.member const avatar = avatarMap.get(info.member.platformId) - return avatar ? { ...info.member, avatar } : info.member - }) + return avatar ? { ...member, avatar } : member + })) const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar) @@ -3683,6 +3811,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -4498,13 +4627,14 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - const groupNicknameCandidates = (isGroup && !useCompactColumns) + const groupNicknameCandidates = isGroup ? this.buildGroupNicknameIdCandidates([ ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] - const groupNicknamesMap = (isGroup && !useCompactColumns) + const groupNicknamesMap = isGroup ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() @@ -4623,30 +4753,26 @@ class ExportService { let senderRemark: string = '' let senderGroupNickname: string = '' // 群昵称 - - if (msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { // 我发送的消息 senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid senderRemark = '' - } else if (isGroup && msg.senderUsername) { - // 群消息 - senderWxid = msg.senderUsername - - // 用 getContact 获取联系人详情,分别取昵称和备注 - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - // nickName 才是真正的昵称 - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - // 身份:有备注显示备注,没有显示昵称 - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRemark = '' - senderRole = msg.senderUsername - } } else { // 单聊对方消息 - 用 getContact 获取联系人详情 senderWxid = sessionId @@ -4662,12 +4788,6 @@ class ExportService { } } - // 获取群昵称 (仅群聊且完整列模式) - if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) - } - - const row = worksheet.getRow(currentRow) row.height = 24 @@ -4843,6 +4963,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -4905,7 +5026,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5063,21 +5185,23 @@ class ExportService { let senderNickname: string let senderRemark = '' - if (msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderRole = senderProfile.displayName + } else if (msg.isSend) { senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid - } else if (isGroup && msg.senderUsername) { - senderWxid = msg.senderUsername - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRole = msg.senderUsername - } } else { senderWxid = sessionId const contactDetail = await getContactCached(sessionId) @@ -5149,6 +5273,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -5200,7 +5325,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5330,7 +5456,17 @@ class ExportService { } let talker = myInfo.displayName || '我' - if (!msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : senderWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + talker = senderProfile.displayName + } else if (!msg.isSend) { const contactDetail = await getContactCached(senderWxid) const senderNickname = contactDetail.success && contactDetail.contact ? (contactDetail.contact.nickName || senderWxid) @@ -5570,7 +5706,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5778,11 +5915,16 @@ class ExportService { const isSenderMe = msg.isSend const senderInfo = collected.memberSet.get(msg.senderUsername)?.member - const senderName = isSenderMe - ? (myInfo.displayName || '我') - : (isGroup - ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) - : (sessionInfo.displayName || sessionId)) + const senderName = isGroup + ? (await this.resolveExportDisplayProfile( + isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), + isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] + )).displayName + : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId)) const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 86ea6c9..bfb8ce1 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -340,6 +340,7 @@ class HttpService { const trimmedRows = allRows.slice(0, limit) const finalHasMore = hasMore || allRows.length > limit const messages = chatService.mapRowsToMessagesForApi(trimmedRows) + await this.backfillMissingSenderUsernames(talker, messages) return { success: true, messages, hasMore: finalHasMore } } finally { await wcdbService.closeMessageCursor(cursor) @@ -359,6 +360,41 @@ class HttpService { return Math.min(Math.max(parsed, min), max) } + private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise { + if (!talker.endsWith('@chatroom')) return + + const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim()) + if (targets.length === 0) return + + const myWxid = (this.configService.get('myWxid') || '').trim() + for (const msg of targets) { + const localId = Number(msg.localId || 0) + if (Number.isFinite(localId) && localId > 0) { + try { + const detail = await wcdbService.getMessageById(talker, localId) + if (detail.success && detail.message) { + const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0] + if (hydrated?.senderUsername) { + msg.senderUsername = hydrated.senderUsername + } + if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) { + msg.isSend = hydrated.isSend + } + if (!msg.rawContent && hydrated?.rawContent) { + msg.rawContent = hydrated.rawContent + } + } + } catch (error) { + console.warn('[HttpService] backfill sender failed:', error) + } + } + + if (!msg.senderUsername && msg.isSend === 1 && myWxid) { + msg.senderUsername = myWxid + } + } + } + private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean { for (const key of keys) { const raw = url.searchParams.get(key) @@ -778,6 +814,49 @@ class HttpService { return {} } + private lookupGroupNickname(groupNicknamesMap: Map, sender: string): string { + if (!sender) return '' + return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '' + } + + private resolveChatLabSenderInfo( + msg: Message, + talkerId: string, + talkerName: string, + myWxid: string, + isGroup: boolean, + senderNames: Record, + groupNicknamesMap: Map + ): { sender: string; accountName: string; groupNickname?: string } { + let sender = String(msg.senderUsername || '').trim() + let usedUnknownPlaceholder = false + const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase() + const isSelf = msg.isSend === 1 || sameAsMe + + if (!sender && isSelf && myWxid) { + sender = myWxid + } + + if (!sender) { + if (msg.localType === 10000 || msg.localType === 266287972401) { + sender = talkerId + } else { + sender = `unknown_sender_${msg.localId || msg.createTime || 0}` + usedUnknownPlaceholder = true + } + } + + const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : '' + const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender) + const accountName = isSelf ? '我' : (displayName || '未知发送者') + + return { + sender, + accountName, + groupNickname: groupNickname || undefined + } + } + /** * 转换为 ChatLab 格式 */ @@ -817,36 +896,24 @@ class HttpService { // 构建成员列表 const memberMap = new Map() for (const msg of messages) { - const sender = msg.senderUsername || '' - if (sender && !memberMap.has(sender)) { - const displayName = senderNames[sender] || sender - const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase() - // 获取群昵称(尝试多种方式) - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' - memberMap.set(sender, { - platformId: sender, - accountName: isSelf ? '我' : displayName, - groupNickname: groupNickname || undefined + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) + if (!memberMap.has(senderInfo.sender)) { + memberMap.set(senderInfo.sender, { + platformId: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname }) } } // 转换消息 const chatLabMessages: ChatLabMessage[] = messages.map(msg => { - const sender = msg.senderUsername || '' - const isSelf = msg.isSend === 1 || sender === myWxid - const accountName = isSelf ? '我' : (senderNames[sender] || sender) - // 获取该发送者的群昵称 - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) return { - sender, - accountName, - groupNickname: groupNickname || undefined, + sender: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname, timestamp: msg.createTime, type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 13dce67..a78b7ed 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -414,23 +414,33 @@ export class ImageDecryptService { if (!skipResolvedCache) { if (imageMd5) { const cached = this.resolvedCache.get(imageMd5) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageMd5, preferred) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred) + return preferred + } } if (imageDatName) { const cached = this.resolvedCache.get(imageDatName) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageDatName, preferred) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred) + return preferred + } } } // 1. 通过 MD5 快速定位 (MsgAttach 目录) if (imageMd5) { - const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) if (res) return res } @@ -439,16 +449,17 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageMd5, hardlinkPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath }) + this.cacheDatPath(accountDir, imageMd5, preferredPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 // 尝试在同一目录下查找高清图变体(快速查找,不遍历) - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) @@ -462,16 +473,19 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (fallbackPath) { - const isThumb = this.isThumbnailPath(fallbackPath) + const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath }) - this.cacheDatPath(accountDir, imageDatName, fallbackPath) - return fallbackPath + this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath) + return preferredPath } // 找到缩略图但要求高清图,尝试同目录查找高清图变体 - const hdPath = this.findHdVariantInSameDir(fallbackPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath) return hdPath } return null @@ -484,14 +498,15 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath @@ -510,9 +525,10 @@ export class ImageDecryptService { if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred // 缓存的是缩略图,尝试找高清图 - const hdPath = this.findHdVariantInSameDir(cached) + const hdPath = this.findHdVariantInSameDir(preferred) if (hdPath) return hdPath } } @@ -801,7 +817,8 @@ export class ImageDecryptService { const key = `${accountDir}|${datName}` const cached = this.resolvedCache.get(key) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred } const root = join(accountDir, 'msg', 'attach') @@ -810,7 +827,7 @@ export class ImageDecryptService { // 优化1:快速概率性查找 // 包含:1. 基于文件名的前缀猜测 (旧版) // 2. 基于日期的最近月份扫描 (新版无索引时) - const fastHit = await this.fastProbabilisticSearch(root, datName) + const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit @@ -830,33 +847,28 @@ export class ImageDecryptService { * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { + private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() - let baseName = lowerName - if (baseName.endsWith('.dat')) { - baseName = baseName.slice(0, -4) - if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) { - baseName = baseName.slice(0, -3) - } else if (baseName.endsWith('_thumb')) { - baseName = baseName.slice(0, -6) - } - } + const baseName = this.normalizeDatBase(lowerName) + const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail) const candidates: string[] = [] if (/^[a-f0-9]{32}$/.test(baseName)) { const dir1 = baseName.substring(0, 2) const dir2 = baseName.substring(2, 4) - candidates.push( - join(root, dir1, dir2, datName), - join(root, dir1, dir2, 'Img', datName), - join(root, dir1, dir2, 'mg', datName), - join(root, dir1, dir2, 'Image', datName) - ) + for (const targetName of targetNames) { + candidates.push( + join(root, dir1, dir2, targetName), + join(root, dir1, dir2, 'Img', targetName), + join(root, dir1, dir2, 'mg', targetName), + join(root, dir1, dir2, 'Image', targetName) + ) + } } for (const path of candidates) { @@ -883,13 +895,6 @@ export class ImageDecryptService { months.push(mStr) } - const targetNames = [datName] - if (baseName !== lowerName) { - targetNames.push(`${baseName}.dat`) - targetNames.push(`${baseName}_t.dat`) - targetNames.push(`${baseName}_thumb.dat`) - } - const batchSize = 20 for (let i = 0; i < sessionDirs.length; i += batchSize) { const batch = sessionDirs.slice(i, i + batchSize) @@ -919,36 +924,13 @@ export class ImageDecryptService { /** * 在同一目录下查找高清图变体 - * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat + * 优先 `_h`,再回退其他非缩略图变体 */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) - const fileName = basename(thumbPath).toLowerCase() - - // 提取基础名称(去掉 _t.dat 或 .t.dat) - let baseName = fileName - if (baseName.endsWith('_t.dat')) { - baseName = baseName.slice(0, -6) - } else if (baseName.endsWith('.t.dat')) { - baseName = baseName.slice(0, -6) - } else { - return null - } - - // 尝试查找高清图变体 - const variants = [ - `${baseName}_h.dat`, - `${baseName}.h.dat`, - `${baseName}.dat` - ] - - for (const variant of variants) { - const variantPath = join(dir, variant) - if (existsSync(variantPath)) { - return variantPath - } - } + const fileName = basename(thumbPath) + return this.findPreferredDatVariantInDir(dir, fileName, false) } catch { } return null } @@ -998,7 +980,86 @@ export class ImageDecryptService { void worker.terminate() resolve(null) }) - }) + }) + } + + private stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower + } + + private getDatVariantPriority(name: string): number { + const lower = name.toLowerCase() + const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!this.hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (this.isThumbnailDat(lower)) return 100 + return 350 + } + + private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] { + if (!baseName) return [] + const names = [ + `${baseName}_h.dat`, + `${baseName}.h.dat`, + `${baseName}.dat`, + `${baseName}_hd.dat`, + `${baseName}.hd.dat`, + `${baseName}_c.dat`, + `${baseName}.c.dat` + ] + if (allowThumbnail) { + names.push( + `${baseName}_thumb.dat`, + `${baseName}.thumb.dat`, + `${baseName}_t.dat`, + `${baseName}.t.dat` + ) + } + return Array.from(new Set(names)) + } + + private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null { + let entries: string[] + try { + entries = readdirSync(dirPath) + } catch { + return null + } + const target = this.normalizeDatBase(baseName.toLowerCase()) + let bestPath: string | null = null + let bestScore = Number.NEGATIVE_INFINITY + for (const entry of entries) { + const lower = entry.toLowerCase() + if (!lower.endsWith('.dat')) continue + if (!allowThumbnail && this.isThumbnailDat(lower)) continue + const baseLower = lower.slice(0, -4) + if (this.normalizeDatBase(baseLower) !== target) continue + const score = this.getDatVariantPriority(lower) + if (score > bestScore) { + bestScore = score + bestPath = join(dirPath, entry) + } + } + return bestPath + } + + private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string { + const lower = datPath.toLowerCase() + if (!lower.endsWith('.dat')) return datPath + const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail) + return preferred || datPath } private normalizeDatBase(name: string): string { @@ -1006,18 +1067,21 @@ export class ImageDecryptService { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + for (;;) { + const stripped = this.stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base } private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return this.stripDatVariantSuffix(baseLower) !== baseLower } private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) } @@ -1206,24 +1270,7 @@ export class ImageDecryptService { } private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { - let entries: string[] - try { - entries = readdirSync(dirPath) - } catch { - return null - } - const target = this.normalizeDatBase(baseName.toLowerCase()) - for (const entry of entries) { - const lower = entry.toLowerCase() - if (!lower.endsWith('.dat')) continue - if (this.isThumbnailDat(lower)) continue - const baseLower = lower.slice(0, -4) - // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) - if (!this.hasXVariant(baseLower)) continue - if (this.normalizeDatBase(baseLower) !== target) continue - return join(dirPath, entry) - } - return null + return this.findPreferredDatVariantInDir(dirPath, baseName, false) } private isNonThumbnailVariantDat(datPath: string): boolean { @@ -1231,8 +1278,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) - // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) - return this.hasXVariant(baseLower) + return this.isLikelyImageDatBase(baseLower) } private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { @@ -1858,7 +1904,7 @@ export class ImageDecryptService { private hasXVariant(base: string): boolean { const lower = base.toLowerCase() - return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + return this.stripDatVariantSuffix(lower) !== lower } private isHdPath(p: string): boolean { diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 2caa66b..66345c2 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -715,6 +715,68 @@ export class KeyService { return wxid.substring(0, second) } + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const cleanedWxid = this.cleanWxid(wxid) + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') + const aesKey = md5Full.substring(0, 16) + return { xorKey, aesKey } + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise { + const candidates: string[] = [] + const pushUnique = (value: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) + + if (manualDir) { + const normalized = manualDir.replace(/[\\/]+$/, '') + const dirName = normalized.split(/[\\/]/).pop() ?? '' + if (dirName.startsWith('wxid_')) pushUnique(dirName) + + const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i) + if (marker) { + const root = normalized.slice(0, marker.index! + marker[0].length) + try { + const { readdirSync, statSync } = await import('fs') + const { join } = await import('path') + for (const entry of readdirSync(root)) { + if (!entry.startsWith('wxid_')) continue + const full = join(root, entry) + try { + if (statSync(full).isDirectory()) pushUnique(entry) + } catch { } + } + } catch { } + } + } + + pushUnique('unknown') + return candidates + } + async autoGetImageKey( manualDir?: string, onProgress?: (message: string) => void, @@ -750,52 +812,34 @@ export class KeyService { const codes: number[] = accounts[0].keys.map((k: any) => k.code) console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) - // 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown) - let targetWxid = '' - - // 方案1: 直接使用传入的wxidParam(最优先) - if (wxidParam && wxidParam.startsWith('wxid_')) { - targetWxid = wxidParam - console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid) + const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam) + let verifyCiphertext: Buffer | null = null + if (manualDir && existsSync(manualDir)) { + const template = await this._findTemplateData(manualDir, 32) + verifyCiphertext = template.ciphertext } - - // 方案2: 从 manualDir 提取前端已配置好的正确 wxid - // 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234" - if (!targetWxid && manualDir) { - const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' - if (dirName.startsWith('wxid_')) { - targetWxid = dirName - console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid) + + if (verifyCiphertext) { + onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) + for (const candidateWxid of wxidCandidates) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue + onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) + return { success: true, xorKey, aesKey } + } } + return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } } - // 方案3: 回退到 DLL 发现的第一个(可能是 unknown) - if (!targetWxid) { - targetWxid = accounts[0].wxid - console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid) - } - - // CleanWxid: 截断到第二个下划线,与 xkey 算法一致 - const cleanedWxid = this.cleanWxid(targetWxid) - console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid) - - // 用 cleanedWxid + code 本地计算密钥 - // xorKey = code & 0xFF - // aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16) - const code = codes[0] - const xorKey = code & 0xFF - const dataToHash = code.toString() + cleanedWxid - const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') - const aesKey = md5Full.substring(0, 16) - - onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`) - console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey) - - return { - success: true, - xorKey, - aesKey - } + // 无模板密文可验真时回退旧策略 + const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown' + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) + return { success: true, xorKey, aesKey } } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 764337f..1699c87 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,8 +1,10 @@ import { app, shell } from 'electron' -import { join } from 'path' +import { join, basename, dirname } from 'path' import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' +import crypto from 'crypto' +import { homedir } from 'os' type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } @@ -14,9 +16,14 @@ export class KeyServiceMac { private initialized = false private GetDbKey: any = null - private ScanMemoryForImageKey: any = null - private FreeString: any = null private ListWeChatProcesses: any = null + private libSystem: any = null + private machTaskSelf: any = null + private taskForPid: any = null + private machVmRegion: any = null + private machVmReadOverwrite: any = null + private machPortDeallocate: any = null + private _needsElevation = false private getHelperPath(): string { const isPackaged = app.isPackaged @@ -43,6 +50,26 @@ export class KeyServiceMac { throw new Error('xkey_helper not found') } + private getImageScanHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper')) + candidates.push(join(process.resourcesPath, 'image_scan_helper')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'image_scan_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('image_scan_helper not found') + } + private getDylibPath(): string { const isPackaged = app.isPackaged const candidates: string[] = [] @@ -81,8 +108,6 @@ export class KeyServiceMac { this.lib = this.koffi.load(dylibPath) this.GetDbKey = this.lib.func('const char* GetDbKey()') - this.ScanMemoryForImageKey = this.lib.func('const char* ScanMemoryForImageKey(int pid, const char* ciphertext)') - this.FreeString = this.lib.func('void FreeString(const char* str)') this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') this.initialized = true @@ -97,30 +122,18 @@ export class KeyServiceMac { ): Promise { try { onStatus?.('正在获取数据库密钥...', 0) - let parsed = await this.getDbKeyParsed(timeoutMs, onStatus) - console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw) - - // ATTACH_FAILED 时自动走图形化授权,再重试一次 - if (!parsed.success && parsed.code === 'ATTACH_FAILED') { - onStatus?.('检测到调试权限不足,正在请求系统授权...', 0) - const permissionOk = await this.enableDebugPermissionWithPrompt() - if (permissionOk) { - onStatus?.('授权完成,正在重试获取密钥...', 0) - parsed = await this.getDbKeyParsed(timeoutMs, onStatus) - console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw) - } else { - onStatus?.('已取消系统授权', 2) - return { success: false, error: '已取消系统授权' } + onStatus?.('正在请求管理员授权并执行 helper...', 0) + let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } + try { + const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus) + parsed = this.parseDbKeyResult(elevatedResult) + console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw) + } catch (e: any) { + const msg = `${e?.message || e}` + if (msg.includes('(-128)') || msg.includes('User canceled')) { + return { success: false, error: '已取消管理员授权' } } - } - - if (!parsed.success && parsed.code === 'ATTACH_FAILED') { - // DevToolsSecurity 仍不足时,自动拉起开发者工具权限页面 - await this.openDeveloperToolsPrivacySettings() - await this.revealCurrentExecutableInFinder() - const msg = `无法附加到微信进程。已打开“开发者工具”设置,并在访达中定位当前运行程序。\n请在“隐私与安全性 -> 开发者工具”点击“+”添加并允许:${process.execPath}` - onStatus?.(msg, 2) - return { success: false, error: msg } + throw e } if (!parsed.success) { @@ -157,15 +170,70 @@ export class KeyServiceMac { timeoutMs: number, onStatus?: (message: string, level: number) => void ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { + const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) + return this.parseDbKeyResult(helperResult) + } + + private async getWeChatPid(): Promise { try { - const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) - return this.parseDbKeyResult(helperResult) - } catch (e: any) { - console.warn('[KeyServiceMac] helper unavailable, fallback to dylib:', e?.message || e) - if (!this.initialized) { - await this.initialize() + // 优先使用 pgrep -x 精确匹配进程名 + try { + const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat']) + const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore and fallback } - return this.parseDbKeyResult(this.GetDbKey()) + + // pgrep -f 匹配完整命令行路径(打包后 pgrep -x 可能失败时的备选) + try { + const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']) + const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore and fallback to ps + } + + const { stdout } = await execFileAsync('/bin/ps', ['-A', '-o', 'pid,comm,command']) + const lines = stdout.split('\n').slice(1) + + const candidates: Array<{ pid: number; comm: string; command: string }> = [] + for (const line of lines) { + const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/) + if (!match) continue + + const pid = parseInt(match[1], 10) + const comm = match[2] + const command = match[3] + + // 打包后 command 列可能被截断或为空,同时检查 comm 列 + const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') || + command.includes('/Contents/MacOS/WeChat') || + comm === 'WeChat' + if (pathMatch) candidates.push({ pid, comm, command }) + } + + if (candidates.length === 0) throw new Error('WeChat process not found') + + const filtered = candidates.filter(p => { + const cmd = p.command + return !cmd.includes('WeChatAppEx.app/') && + !cmd.includes('/WeChatAppEx') && + !cmd.includes(' WeChatAppEx') && + !cmd.includes('crashpad_handler') && + !cmd.includes('Helper') && + p.comm !== 'WeChat Helper' + }) + if (filtered.length === 0) throw new Error('No valid WeChat main process found') + + const preferredMain = filtered.filter(p => + p.command.includes('/Contents/MacOS/WeChat') || p.comm === 'WeChat' + ) + const selectedPool = preferredMain.length > 0 ? preferredMain : filtered + const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max) + return selected.pid + } catch (e: any) { + throw new Error('Failed to get WeChat PID: ' + e.message) } } @@ -175,8 +243,14 @@ export class KeyServiceMac { ): Promise { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) + const pid = await this.getWeChatPid() + onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) + // 最佳努力清理同路径残留 helper(普通权限) + try { await execFileAsync('/usr/bin/pkill', ['-f', helperPath], { timeout: 2000 }) } catch { } + return await new Promise((resolve, reject) => { - const child = spawn(helperPath, [String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) + // xkey_helper 参数协议:helper [timeout_ms] + const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' let stdoutBuf = '' @@ -220,7 +294,7 @@ export class KeyServiceMac { stdout += data stdoutBuf += data const parts = stdoutBuf.split(/\r?\n/) - stdoutBuf = parts.pop() || '' + stdoutBuf = parts.pop()! }) child.stderr.on('data', (chunk: Buffer | string) => { @@ -228,7 +302,7 @@ export class KeyServiceMac { stderr += data stderrBuf += data const parts = stderrBuf.split(/\r?\n/) - stderrBuf = parts.pop() || '' + stderrBuf = parts.pop()! for (const line of parts) processHelperLine(line.trim()) }) @@ -275,6 +349,51 @@ export class KeyServiceMac { }) } + private shellSingleQuote(text: string): string { + return `'${String(text).replace(/'/g, `'\\''`)}'` + } + + private async getDbKeyByHelperElevated( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise { + const helperPath = this.getHelperPath() + const waitMs = Math.max(timeoutMs, 30_000) + const pid = await this.getWeChatPid() + // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 + const scriptLines = [ + `set helperPath to ${JSON.stringify(helperPath)}`, + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + 'do shell script cmd with administrator privileges' + ] + onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) + + let stdout = '' + try { + const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: waitMs + 20_000 + }) + stdout = result.stdout + } catch (e: any) { + const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim() + throw new Error(msg || 'elevated helper execution failed') + } + + const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) throw new Error('elevated helper returned empty output') + + let payload: any + try { + payload = JSON.parse(last) + } catch { + throw new Error('elevated helper returned invalid json: ' + last) + } + if (payload?.success === true && typeof payload?.key === 'string') return payload.key + if (typeof payload?.result === 'string') return payload.result + throw new Error('elevated helper json missing key/result') + } + private mapDbKeyErrorMessage(code?: string, detail?: string): string { if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' if (code === 'ATTACH_FAILED') { @@ -315,7 +434,7 @@ export class KeyServiceMac { ] try { - await execFileAsync('osascript', script.flatMap(line => ['-e', line]), { + await execFileAsync('/usr/bin/osascript', script.flatMap(line => ['-e', line]), { timeout: 30_000 }) return true @@ -351,18 +470,52 @@ export class KeyServiceMac { onStatus?: (message: string) => void, wxid?: string ): Promise { - onStatus?.('macOS 请使用内存扫描方式') - return { success: false, error: 'macOS 请使用内存扫描方式' } + try { + onStatus?.('正在从缓存目录扫描图片密钥...') + const codes = this.collectKvcommCodes(accountPath) + if (codes.length === 0) { + return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } + } + + const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) + if (wxidCandidates.length === 0) { + return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' } + } + + // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 + let verifyCiphertext: Buffer | null = null + if (accountPath && existsSync(accountPath)) { + const template = await this._findTemplateData(accountPath, 32) + verifyCiphertext = template.ciphertext + } + if (verifyCiphertext) { + onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) + for (const candidateWxid of wxidCandidates) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue + onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } + } + return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } + } + + // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) + const fallbackWxid = wxidCandidates[0] + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + return { success: true, xorKey, aesKey } + } catch (e: any) { + return { success: false, error: `自动获取图片密钥失败: ${e.message}` } + } } async autoGetImageKeyByMemoryScan( userDir: string, onProgress?: (message: string) => void ): Promise { - if (!this.initialized) { - await this.initialize() - } - try { // 1. 查找模板文件获取密文和 XOR 密钥 onProgress?.('正在查找模板文件...') @@ -392,7 +545,7 @@ export class KeyServiceMac { while (Date.now() < deadline) { scanCount++ onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) - const aesKey = await this._scanMemoryForAesKey(pid, ciphertext) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) if (aesKey) { onProgress?.('密钥获取成功') return { success: true, xorKey, aesKey } @@ -461,10 +614,199 @@ export class KeyServiceMac { return { ciphertext, xorKey } } - private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise { - const ciphertextHex = ciphertext.toString('hex') - const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex) - return aesKey || null + private ensureMachApis(): boolean { + if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true + try { + if (!this.koffi) this.koffi = require('koffi') + this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib') + this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', []) + this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')]) + this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [ + 'uint32', + this.koffi.out('uint64*'), + this.koffi.out('uint64*'), + 'int', + 'void*', + this.koffi.out('uint32*'), + this.koffi.out('uint32*') + ]) + this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [ + 'uint32', + 'uint64', + 'uint64', + 'void*', + this.koffi.out('uint64*') + ]) + this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32']) + return true + } catch (e) { + console.error('[KeyServiceMac] 初始化 Mach API 失败:', e) + return false + } + } + + private async _scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (message: string) => void + ): Promise { + // 优先通过 image_scan_helper 子进程调用 + try { + const helperPath = this.getImageScanHelperPath() + const ciphertextHex = ciphertext.toString('hex') + + // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) + if (!this._needsElevation) { + const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false) + if (direct.key) return direct.key + if (direct.permissionError) { + console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') + this._needsElevation = true + onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...') + } + } + + // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) + if (this._needsElevation) { + const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true) + if (elevated.key) return elevated.key + } + } catch (e: any) { + console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message) + } + + // fallback: 直接通过 Mach API 扫描内存(Electron 进程可能没有 task_for_pid 权限) + if (!this.ensureMachApis()) return null + + const VM_PROT_READ = 0x1 + const VM_PROT_WRITE = 0x2 + const VM_REGION_BASIC_INFO_64 = 9 + const VM_REGION_BASIC_INFO_COUNT_64 = 9 + const KERN_SUCCESS = 0 + const MAX_REGION_SIZE = 50 * 1024 * 1024 + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + const selfTask = this.machTaskSelf() + const taskBuf = Buffer.alloc(4) + const attachKr = this.taskForPid(selfTask, pid, taskBuf) + const task = taskBuf.readUInt32LE(0) + if (attachKr !== KERN_SUCCESS || !task) return null + + try { + const regions: Array<[number, number]> = [] + let address = 0 + + while (address < 0x7FFFFFFFFFFF) { + const addrBuf = Buffer.alloc(8) + addrBuf.writeBigUInt64LE(BigInt(address), 0) + const sizeBuf = Buffer.alloc(8) + const infoBuf = Buffer.alloc(64) + const countBuf = Buffer.alloc(4) + countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0) + const objectBuf = Buffer.alloc(4) + + const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf) + if (kr !== KERN_SUCCESS) break + + const base = Number(addrBuf.readBigUInt64LE(0)) + const size = Number(sizeBuf.readBigUInt64LE(0)) + const protection = infoBuf.readInt32LE(0) + const objectName = objectBuf.readUInt32LE(0) + if (objectName) { + try { this.machPortDeallocate(selfTask, objectName) } catch { } + } + + if ((protection & VM_PROT_READ) !== 0 && + (protection & VM_PROT_WRITE) !== 0 && + size > 0 && + size <= MAX_REGION_SIZE) { + regions.push([base, size]) + } + + const next = base + size + if (next <= address) break + address = next + } + + const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) + + for (let ri = 0; ri < regions.length; ri++) { + const [base, size] = regions[ri] + if (ri % 20 === 0) { + onProgress?.(`扫描进度 ${ri}/${regions.length}...`) + await new Promise(r => setTimeout(r, 1)) + } + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const chunk = Buffer.alloc(chunkSize) + const outSizeBuf = Buffer.alloc(8) + const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf) + const bytesRead = Number(outSizeBuf.readBigUInt64LE(0)) + offset += chunkSize + + if (kr !== KERN_SUCCESS || bytesRead <= 0) { + trailing = null + continue + } + + const current = chunk.subarray(0, bytesRead) + const data = trailing ? Buffer.concat([trailing, current]) : current + const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext) + if (key) return key + // 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中) + const fallbackKey = this._searchAny16Key(data, ciphertext) + if (fallbackKey) return fallbackKey + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + } + } + return null + } finally { + try { this.machPortDeallocate(selfTask, task) } catch { } + } + } + + private _spawnScanHelper( + helperPath: string, pid: number, ciphertextHex: string, elevated: boolean + ): Promise<{ key: string | null; permissionError: boolean }> { + return new Promise((resolve, reject) => { + let child: ReturnType + if (elevated) { + const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], + { stdio: ['ignore', 'pipe', 'pipe'] }) + } else { + child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] }) + } + const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]' + let stdout = '', stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + console.log(tag, chunk.toString().trim()) + }) + child.on('error', reject) + child.on('close', () => { + const permissionError = !elevated && stderr.includes('task_for_pid failed') + try { + const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) { resolve({ key: null, permissionError }); return } + const payload = JSON.parse(last) + resolve({ + key: payload?.success && payload?.aesKey ? payload.aesKey : null, + permissionError + }) + } catch { + resolve({ key: null, permissionError }) + } + }) + setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000) + }) } private async findWeChatPid(): Promise { @@ -481,5 +823,224 @@ export class KeyServiceMac { cleanup(): void { this.lib = null this.initialized = false + this.libSystem = null + this.machTaskSelf = null + this.taskForPid = null + this.machVmRegion = null + this.machVmReadOverwrite = null + this.machPortDeallocate = null + } + + private cleanWxid(wxid: string): string { + const first = wxid.indexOf('_') + if (first === -1) return wxid + const second = wxid.indexOf('_', first + 1) + if (second === -1) return wxid + return wxid.substring(0, second) + } + + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const cleanedWxid = this.cleanWxid(wxid) + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16) + return { xorKey, aesKey } + } + + private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { + const candidates: string[] = [] + const pushUnique = (value: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + // 1) 显式传参优先 + if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) + + if (accountPath) { + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + const dirName = basename(normalized) + // 2) 当前目录名为 wxid_* + if (dirName.startsWith('wxid_')) pushUnique(dirName) + + // 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录 + const marker = '/xwechat_files' + const markerIdx = normalized.indexOf(marker) + if (markerIdx >= 0) { + const root = normalized.slice(0, markerIdx + marker.length) + if (existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + if (!entry.name.startsWith('wxid_')) continue + pushUnique(entry.name) + } + } catch { + // ignore + } + } + } + } + + pushUnique('unknown') + return candidates + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16) + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private collectKvcommCodes(accountPath?: string): number[] { + const codeSet = new Set() + const pattern = /^key_(\d+)_.+\.statistic$/i + + for (const kvcommDir of this.getKvcommCandidates(accountPath)) { + if (!existsSync(kvcommDir)) continue + try { + const files = readdirSync(kvcommDir) + for (const file of files) { + const match = file.match(pattern) + if (!match) continue + const code = Number(match[1]) + if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue + codeSet.add(code) + } + } catch { + // 忽略不可读目录,继续尝试其他候选路径 + } + } + + return Array.from(codeSet) + } + + private getKvcommCandidates(accountPath?: string): string[] { + const home = homedir() + const candidates = new Set([ + // 与用户实测路径一致:Documents/xwechat_files -> Documents/app_data/net/kvcomm + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm') + ]) + + if (accountPath) { + // 规则:把路径中的 xwechat_files 替换为 app_data,然后拼 net/kvcomm + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + const marker = '/xwechat_files' + const idx = normalized.indexOf(marker) + if (idx >= 0) { + const base = normalized.slice(0, idx) + candidates.add(`${base}/app_data/net/kvcomm`) + } + + let cursor = accountPath + for (let i = 0; i < 6; i++) { + candidates.add(join(cursor, 'net', 'kvcomm')) + const next = dirname(cursor) + if (next === cursor) break + cursor = next + } + } + + return Array.from(candidates) + } + + private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this._isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this._isAlphaNum(data[i + j])) { valid = false; break } + } + if (!valid) continue + if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } + } + if (!valid) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _isAlphaNum(b: number): boolean { + return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) + } + + private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + // 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率 + private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i + 16 <= data.length; i++) { + const keyBytes = data.subarray(i, i + 16) + if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue + if (!this._isMostlyPrintableAscii(keyBytes)) continue + return keyBytes.toString('ascii') + } + return null + } + + private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean { + let printable = 0 + for (const b of keyBytes16) { + if (b >= 0x20 && b <= 0x7E) printable++ + } + return printable >= 14 } } diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 5ff3d84..cc75828 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -48,6 +48,38 @@ export class VoiceTranscribeService { private recognizer: OfflineRecognizer | null = null private isInitializing = false + private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env } + const platform = process.platform === 'win32' ? 'win' : process.platform + const platformPkg = `sherpa-onnx-${platform}-${process.arch}` + const candidates = [ + join(__dirname, '..', 'node_modules', platformPkg), + join(__dirname, 'node_modules', platformPkg), + join(process.cwd(), 'node_modules', platformPkg), + process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' + ].filter((item): item is string => Boolean(item) && existsSync(item)) + + if (process.platform === 'darwin') { + const key = 'DYLD_LIBRARY_PATH' + const existing = env[key] || '' + const merged = [...candidates, ...existing.split(':').filter(Boolean)] + env[key] = Array.from(new Set(merged)).join(':') + if (candidates.length === 0) { + console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) + } + } else if (process.platform === 'linux') { + const key = 'LD_LIBRARY_PATH' + const existing = env[key] || '' + const merged = [...candidates, ...existing.split(':').filter(Boolean)] + env[key] = Array.from(new Set(merged)).join(':') + if (candidates.length === 0) { + console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) + } + } + + return env + } + private resolveModelDir(): string { const configured = this.configService.get('whisperModelDir') as string | undefined if (configured) return configured @@ -206,17 +238,20 @@ export class VoiceTranscribeService { } } - const { Worker } = require('worker_threads') + const { fork } = require('child_process') const workerPath = join(__dirname, 'transcribeWorker.js') - const worker = new Worker(workerPath, { - workerData: { - modelPath, - tokensPath, - wavData, - sampleRate: 16000, - languages: supportedLanguages - } + const worker = fork(workerPath, [], { + env: this.buildTranscribeWorkerEnv(), + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + serialization: 'advanced' + }) + worker.send({ + modelPath, + tokensPath, + wavData, + sampleRate: 16000, + languages: supportedLanguages }) let finalTranscript = '' @@ -227,11 +262,13 @@ export class VoiceTranscribeService { } else if (msg.type === 'final') { finalTranscript = msg.text resolve({ success: true, transcript: finalTranscript }) - worker.terminate() + worker.disconnect() + worker.kill() } else if (msg.type === 'error') { console.error('[VoiceTranscribe] Worker 错误:', msg.error) resolve({ success: false, error: msg.error }) - worker.terminate() + worker.disconnect() + worker.kill() } }) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index ed0862a..7e69caa 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -148,13 +148,8 @@ export class WcdbCore { } } - // 使用命名管道 IPC (仅 Windows) + // 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket) startMonitor(callback: (type: string, json: string) => void): boolean { - if (process.platform !== 'win32') { - console.warn('[wcdbCore] Monitor not supported on macOS') - return false - } - if (!this.wcdbStartMonitorPipe) { return false } @@ -178,7 +173,6 @@ export class WcdbCore { } } catch {} } - this.connectMonitorPipe(pipePath) return true } catch (e) { @@ -195,13 +189,18 @@ export class WcdbCore { setTimeout(() => { if (!this.monitorCallback) return - this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { - }) + this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {}) let buffer = '' this.monitorPipeClient.on('data', (data: Buffer) => { - buffer += data.toString('utf8') - const lines = buffer.split('\n') + const rawChunk = data.toString('utf8') + // macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包 + const normalizedChunk = rawChunk + .replace(/\u0000/g, '\n') + .replace(/}\s*{/g, '}\n{') + + buffer += normalizedChunk + const lines = buffer.split(/\r?\n/) buffer = lines.pop() || '' for (const line of lines) { if (line.trim()) { @@ -213,9 +212,22 @@ export class WcdbCore { } } } + + // 兜底:如果没有分隔符但已形成完整 JSON,则直接上报 + const tail = buffer.trim() + if (tail.startsWith('{') && tail.endsWith('}')) { + try { + const parsed = JSON.parse(tail) + this.monitorCallback?.(parsed.action || 'update', tail) + buffer = '' + } catch { + // 不可解析则继续等待下一块数据 + } + } }) this.monitorPipeClient.on('error', () => { + // 保持静默,与现有错误处理策略一致 }) this.monitorPipeClient.on('close', () => { @@ -583,6 +595,8 @@ export class WcdbCore { const resourcePaths = [ dllDir, // DLL 所在目录 dirname(dllDir), // 上级目录 + process.resourcesPath, // 打包后 Contents/Resources + process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources this.resourcesPath, // 配置的资源路径 join(process.cwd(), 'resources') // 开发环境 ].filter(Boolean) diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 6aee8e9..2f5715f 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -136,7 +136,7 @@ export class WcdbService { */ setMonitor(callback: (type: string, json: string) => void): void { this.monitorListener = callback; - this.callWorker('setMonitor').catch(() => { }); + this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }); } /** diff --git a/electron/transcribeWorker.ts b/electron/transcribeWorker.ts index e5a18d1..847ed06 100644 --- a/electron/transcribeWorker.ts +++ b/electron/transcribeWorker.ts @@ -1,13 +1,56 @@ import { parentPort, workerData } from 'worker_threads' +import { existsSync } from 'fs' +import { join } from 'path' interface WorkerParams { modelPath: string tokensPath: string - wavData: Buffer + wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] } sampleRate: number languages?: string[] } +function appendLibrarySearchPath(libDir: string): void { + if (!existsSync(libDir)) return + + if (process.platform === 'darwin') { + const current = process.env.DYLD_LIBRARY_PATH || '' + const paths = current.split(':').filter(Boolean) + if (!paths.includes(libDir)) { + process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':') + } + return + } + + if (process.platform === 'linux') { + const current = process.env.LD_LIBRARY_PATH || '' + const paths = current.split(':').filter(Boolean) + if (!paths.includes(libDir)) { + process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':') + } + } +} + +function prepareSherpaRuntimeEnv(): void { + const platform = process.platform === 'win32' ? 'win' : process.platform + const platformPkg = `sherpa-onnx-${platform}-${process.arch}` + const resourcesPath = (process as any).resourcesPath as string | undefined + + const candidates = [ + // Dev: /project/dist-electron -> /project/node_modules/... + join(__dirname, '..', 'node_modules', platformPkg), + // Fallback for alternate layouts + join(__dirname, 'node_modules', platformPkg), + join(process.cwd(), 'node_modules', platformPkg), + // Packaged app: Resources/app.asar.unpacked/node_modules/... + resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' + ].filter(Boolean) + + for (const dir of candidates) { + appendLibrarySearchPath(dir) + } +} + // 语言标记映射 const LANGUAGE_TAGS: Record = { 'zh': '<|zh|>', @@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean { } async function run() { - if (!parentPort) { - return; + const isForkProcess = !parentPort + const emit = (msg: any) => { + if (parentPort) { + parentPort.postMessage(msg) + return + } + if (typeof process.send === 'function') { + process.send(msg) + } + } + + const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => { + if (Buffer.isBuffer(data)) return data + if (data instanceof Uint8Array) return Buffer.from(data) + if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) { + return Buffer.from((data as any).data) + } + return Buffer.alloc(0) + } + + const readParams = async (): Promise => { + if (parentPort) { + return workerData as WorkerParams + } + + return new Promise((resolve) => { + let settled = false + const finish = (value: WorkerParams | null) => { + if (settled) return + settled = true + resolve(value) + } + process.once('message', (msg) => finish(msg as WorkerParams)) + process.once('disconnect', () => finish(null)) + }) } try { + prepareSherpaRuntimeEnv() + const params = await readParams() + if (!params) return + // 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等) let sherpa: any; try { sherpa = require('sherpa-onnx-node'); } catch (requireError) { - parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); + emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); + if (isForkProcess) process.exit(1) return; } - const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams - const wavData = Buffer.from(rawWavData); + const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params + const wavData = normalizeBuffer(rawWavData); // 确保有有效的语言列表,默认只允许中文 let allowedLanguages = languages || ['zh'] if (allowedLanguages.length === 0) { @@ -151,16 +232,18 @@ async function run() { if (isLanguageAllowed(result, allowedLanguages)) { const processedText = richTranscribePostProcess(result.text) - parentPort.postMessage({ type: 'final', text: processedText }) + emit({ type: 'final', text: processedText }) + if (isForkProcess) process.exit(0) } else { - parentPort.postMessage({ type: 'final', text: '' }) + emit({ type: 'final', text: '' }) + if (isForkProcess) process.exit(0) } } catch (error) { - parentPort.postMessage({ type: 'error', error: String(error) }) + emit({ type: 'error', error: String(error) }) + if (isForkProcess) process.exit(1) } } run(); - diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 333527a..8a49cad 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -20,15 +20,17 @@ if (parentPort) { result = { success: true } break case 'setMonitor': - core.setMonitor((type, json) => { + { + const monitorOk = core.setMonitor((type, json) => { parentPort!.postMessage({ id: -1, type: 'monitor', payload: { type, json } }) }) - result = { success: true } + result = { success: monitorOk } break + } case 'testConnection': result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) break diff --git a/package.json b/package.json index 1666f1e..c3bce01 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "hardenedRuntime": false, "gatekeeperAssess": false, "entitlements": "electron/entitlements.mac.plist", - "entitlementsInherit": "electron/entitlements.mac.plist" + "entitlementsInherit": "electron/entitlements.mac.plist", + "icon": "resources/icon.icns" }, "win": { "target": [ @@ -120,6 +121,10 @@ { "from": "electron/assets/wasm/", "to": "assets/wasm/" + }, + { + "from": "resources/icon.icns", + "to": "icon.icns" } ], "files": [ @@ -129,6 +134,8 @@ "asarUnpack": [ "node_modules/silk-wasm/**/*", "node_modules/sherpa-onnx-node/**/*", + "node_modules/sherpa-onnx-*/*", + "node_modules/sherpa-onnx-*/**/*", "node_modules/ffmpeg-static/**/*" ], "extraFiles": [ @@ -148,6 +155,7 @@ "from": "resources/vcruntime140_1.dll", "to": "." } - ] + ], + "icon": "resources/icon.icns" } } diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..70df606 Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/image_scan_entitlements.plist b/resources/image_scan_entitlements.plist new file mode 100644 index 0000000..023065e --- /dev/null +++ b/resources/image_scan_entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.debugger + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/resources/image_scan_helper b/resources/image_scan_helper new file mode 100755 index 0000000..b10856d Binary files /dev/null and b/resources/image_scan_helper differ diff --git a/resources/image_scan_helper.c b/resources/image_scan_helper.c new file mode 100644 index 0000000..39bcf27 --- /dev/null +++ b/resources/image_scan_helper.c @@ -0,0 +1,77 @@ +/* + * image_scan_helper - 轻量包装程序 + * 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey + * 用法: image_scan_helper + * 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."} + */ +#include +#include +#include +#include +#include +#include + +typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext); +typedef void (*FreeStringFn)(const char* str); + +int main(int argc, char* argv[]) { + if (argc != 3) { + fprintf(stderr, "Usage: %s \n", argv[0]); + printf("{\"success\":false,\"error\":\"invalid arguments\"}\n"); + return 1; + } + + int pid = atoi(argv[1]); + const char* ciphertext_hex = argv[2]; + + if (pid <= 0) { + printf("{\"success\":false,\"error\":\"invalid pid\"}\n"); + return 1; + } + + /* 定位 dylib: 与自身同目录下的 libwx_key.dylib */ + char exe_path[4096]; + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) != 0) { + printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n"); + return 1; + } + + char* dir = dirname(exe_path); + char dylib_path[4096]; + snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir); + + void* handle = dlopen(dylib_path, RTLD_LAZY); + if (!handle) { + printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror()); + return 1; + } + + ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey"); + if (!scan_fn) { + printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n"); + dlclose(handle); + return 1; + } + + FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString"); + + fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex); + + const char* result = scan_fn(pid, ciphertext_hex); + + if (result && strlen(result) > 0) { + /* 检查是否是错误 */ + if (strncmp(result, "ERROR", 5) == 0) { + printf("{\"success\":false,\"error\":\"%s\"}\n", result); + } else { + printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result); + } + if (free_fn) free_fn(result); + } else { + printf("{\"success\":false,\"error\":\"no key found\"}\n"); + } + + dlclose(handle); + return 0; +} diff --git a/resources/libwx_key.dylib b/resources/libwx_key.dylib index 10773c0..59c673a 100755 Binary files a/resources/libwx_key.dylib and b/resources/libwx_key.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 3834eab..7ccdf6f 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 4208707..3a58257 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/xkey_helper b/resources/xkey_helper index 6ffda57..02ae7c5 100755 Binary files a/resources/xkey_helper and b/resources/xkey_helper differ diff --git a/src/App.tsx b/src/App.tsx index f50a7b2..e287a68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,10 +138,6 @@ function App() { const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode document.documentElement.setAttribute('data-theme', currentTheme) document.documentElement.setAttribute('data-mode', effectiveMode) - const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a' - if (!isOnboardingWindow && !isNotificationWindow) { - window.electronAPI.window.setTitleBarOverlay({ symbolColor }) - } } applyMode(themeMode) diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index 40d8243..a1abf71 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -46,7 +46,6 @@ export function GlobalSessionMonitor() { return () => { removeListener() } - } else { } return () => { } }, []) diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 810815b..31d4725 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -61,6 +61,16 @@ gap: 4px; padding: 6px; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); + opacity: 0; + transform: translateY(8px) scale(0.95); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + + &.open { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + } } .sidebar-user-menu-item { @@ -265,6 +275,185 @@ gap: 4px; } +.sidebar-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 1100; + padding: 20px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.sidebar-dialog { + width: min(420px, 100%); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); + padding: 18px 18px 16px; + animation: slideUp 0.25s ease; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } + + p { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.sidebar-wxid-list { + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.sidebar-wxid-item { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgba(99, 102, 241, 0.32); + background: var(--bg-tertiary); + } + + &.current { + border-color: rgba(99, 102, 241, 0.5); + background: var(--bg-tertiary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .wxid-avatar { + width: 40px; + height: 40px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: var(--on-primary); + font-size: 16px; + font-weight: 600; + } + } + + .wxid-info { + flex: 1; + min-width: 0; + text-align: left; + } + + .wxid-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .wxid-id { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .current-badge { + padding: 4px 10px; + border-radius: 6px; + background: var(--primary); + color: var(--on-primary); + font-size: 11px; + font-weight: 600; + flex-shrink: 0; + } +} + +.sidebar-dialog-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + + button { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: var(--bg-tertiary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } +} + .sidebar-clear-dialog-overlay { position: fixed; inset: 0; @@ -274,6 +463,7 @@ justify-content: center; z-index: 1100; padding: 20px; + animation: fadeIn 0.2s ease; } .sidebar-clear-dialog { @@ -283,6 +473,7 @@ border-radius: 16px; box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); padding: 18px 18px 16px; + animation: slideUp 0.25s ease; h3 { margin: 0; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e215448..e6d0147 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react' import { useAppStore } from '../stores/appStore' +import { useChatStore } from '../stores/chatStore' +import { useAnalyticsStore } from '../stores/analyticsStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' @@ -15,11 +17,28 @@ interface SidebarUserProfile { } const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' +const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' interface SidebarUserProfileCache extends SidebarUserProfile { updatedAt: number } +interface AccountProfilesCache { + [wxid: string]: { + displayName: string + avatarUrl?: string + alias?: string + updatedAt: number + } +} + +interface WxidOption { + wxid: string + modifiedTime: number + displayName?: string + avatarUrl?: string +} + const readSidebarUserProfileCache = (): SidebarUserProfile | null => { try { const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) @@ -46,11 +65,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { updatedAt: Date.now() } window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) + + // 同时写入账号缓存池 + const accountsCache = readAccountProfilesCache() + accountsCache[profile.wxid] = { + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + alias: profile.alias, + updatedAt: Date.now() + } + window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache)) } catch { // 忽略本地缓存失败,不影响主流程 } } +const readAccountProfilesCache = (): AccountProfilesCache => { + try { + const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed ? parsed : {} + } catch { + return {} + } +} + const normalizeAccountId = (value?: string | null): string => { const trimmed = String(value || '').trim() if (!trimmed) return '' @@ -76,12 +116,14 @@ function Sidebar({ collapsed }: SidebarProps) { displayName: '未识别用户' }) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) - const [showClearAccountDialog, setShowClearAccountDialog] = useState(false) - const [shouldClearCacheData, setShouldClearCacheData] = useState(false) - const [shouldClearExportData, setShouldClearExportData] = useState(false) - const [isClearingAccountData, setIsClearingAccountData] = useState(false) + const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false) + const [wxidOptions, setWxidOptions] = useState([]) + const [isSwitchingAccount, setIsSwitchingAccount] = useState(false) const accountCardWrapRef = useRef(null) const setLocked = useAppStore(state => state.setLocked) + const isDbConnected = useAppStore(state => state.isDbConnected) + const resetChatStore = useChatStore(state => state.reset) + const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) @@ -143,6 +185,9 @@ function Sidebar({ collapsed }: SidebarProps) { const resolvedWxidRaw = String(wxid || '').trim() const cleanedWxid = normalizeAccountId(resolvedWxidRaw) const resolvedWxid = cleanedWxid || resolvedWxidRaw + + if (!resolvedWxidRaw && !resolvedWxid) return + const wxidCandidates = new Set([ resolvedWxidRaw.toLowerCase(), resolvedWxid.trim().toLowerCase(), @@ -168,77 +213,36 @@ function Sidebar({ collapsed }: SidebarProps) { return undefined } - const fallbackDisplayName = resolvedWxid || '未识别用户' + // 并行获取名称和头像 + const [contactResult, avatarResult] = await Promise.allSettled([ + (async () => { + const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean))) + for (const candidate of candidates) { + const contact = await window.electronAPI.chat.getContact(candidate) + if (contact?.remark || contact?.nickName || contact?.alias) { + return contact + } + } + return null + })(), + window.electronAPI.chat.getMyAvatarUrl() + ]) + + const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null + const displayName = pickFirstValidName( + myContact?.remark, + myContact?.nickName, + myContact?.alias + ) || resolvedWxid || '未识别用户' - // 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 patchUserProfile({ wxid: resolvedWxid, - displayName: fallbackDisplayName + displayName, + alias: myContact?.alias, + avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success + ? avatarResult.value.avatarUrl + : undefined }) - - if (!resolvedWxidRaw && !resolvedWxid) return - - // 第二阶段:后台补齐名称(不会阻塞首屏)。 - void (async () => { - try { - let myContact: Awaited> | null = null - for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) { - const contact = await window.electronAPI.chat.getContact(candidate) - if (!contact) continue - if (!myContact) myContact = contact - if (contact.remark || contact.nickName || contact.alias) { - myContact = contact - break - } - } - const fromContact = pickFirstValidName( - myContact?.remark, - myContact?.nickName, - myContact?.alias - ) - - if (fromContact) { - patchUserProfile({ displayName: fromContact }, resolvedWxid) - // 同步补充微信号(alias) - if (myContact?.alias) { - patchUserProfile({ alias: myContact.alias }, resolvedWxid) - } - return - } - - const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean))) - const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets) - const enrichedDisplayName = pickFirstValidName( - enrichedResult.contacts?.[resolvedWxidRaw]?.displayName, - enrichedResult.contacts?.[resolvedWxid]?.displayName, - enrichedResult.contacts?.[cleanedWxid]?.displayName, - enrichedResult.contacts?.self?.displayName, - myContact?.alias - ) - const bestName = enrichedDisplayName - if (bestName) { - patchUserProfile({ displayName: bestName }, resolvedWxid) - } - // 降级分支也补充微信号 - if (myContact?.alias) { - patchUserProfile({ alias: myContact.alias }, resolvedWxid) - } - } catch (nameError) { - console.error('加载侧边栏用户昵称失败:', nameError) - } - })() - - // 第二阶段:后台补齐头像(不会阻塞首屏)。 - void (async () => { - try { - const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() - if (avatarResult.success && avatarResult.avatarUrl) { - patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid) - } - } catch (avatarError) { - console.error('加载侧边栏用户头像失败:', avatarError) - } - })() } catch (error) { console.error('加载侧边栏用户信息失败:', error) } @@ -246,10 +250,7 @@ function Sidebar({ collapsed }: SidebarProps) { const cachedProfile = readSidebarUserProfileCache() if (cachedProfile) { - setUserProfile(prev => ({ - ...prev, - ...cachedProfile - })) + setUserProfile(cachedProfile) } void loadCurrentUser() @@ -263,23 +264,107 @@ function Sidebar({ collapsed }: SidebarProps) { return [...name][0] || '?' } - const isActive = (path: string) => { - return location.pathname === path || location.pathname.startsWith(`${path}/`) - } - const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}` - const canConfirmClear = shouldClearCacheData || shouldClearExportData - - const resetClearDialogState = () => { - setShouldClearCacheData(false) - setShouldClearExportData(false) - setShowClearAccountDialog(false) - } - - const openClearAccountDialog = () => { + const openSwitchAccountDialog = async () => { setIsAccountMenuOpen(false) - setShouldClearCacheData(false) - setShouldClearExportData(false) - setShowClearAccountDialog(true) + if (!isDbConnected) { + window.alert('数据库未连接,无法切换账号') + return + } + const dbPath = await configService.getDbPath() + if (!dbPath) { + window.alert('请先在设置中配置数据库路径') + return + } + try { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + const accountsCache = readAccountProfilesCache() + console.log('[切换账号] 账号缓存:', accountsCache) + + const enrichedWxids = wxids.map(option => { + const normalizedWxid = normalizeAccountId(option.wxid) + const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid] + + if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) { + return { + ...option, + displayName: userProfile.displayName, + avatarUrl: userProfile.avatarUrl + } + } + if (cached) { + console.log('[切换账号] 使用缓存:', option.wxid, cached) + return { + ...option, + displayName: cached.displayName, + avatarUrl: cached.avatarUrl + } + } + return { ...option, displayName: option.wxid } + }) + + setWxidOptions(enrichedWxids) + setShowSwitchAccountDialog(true) + } catch (error) { + console.error('扫描账号失败:', error) + window.alert('扫描账号失败,请稍后重试') + } + } + + const handleSwitchAccount = async (selectedWxid: string) => { + if (!selectedWxid || isSwitchingAccount) return + setIsSwitchingAccount(true) + try { + console.log('[切换账号] 开始切换到:', selectedWxid) + const currentWxid = userProfile.wxid + if (currentWxid === selectedWxid) { + console.log('[切换账号] 已经是当前账号,跳过') + setShowSwitchAccountDialog(false) + setIsSwitchingAccount(false) + return + } + + console.log('[切换账号] 设置新 wxid') + await configService.setMyWxid(selectedWxid) + + console.log('[切换账号] 获取账号配置') + const wxidConfig = await configService.getWxidConfig(selectedWxid) + console.log('[切换账号] 配置内容:', wxidConfig) + if (wxidConfig?.decryptKey) { + console.log('[切换账号] 设置 decryptKey') + await configService.setDecryptKey(wxidConfig.decryptKey) + } + if (typeof wxidConfig?.imageXorKey === 'number') { + console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey) + await configService.setImageXorKey(wxidConfig.imageXorKey) + } + if (wxidConfig?.imageAesKey) { + console.log('[切换账号] 设置 imageAesKey') + await configService.setImageAesKey(wxidConfig.imageAesKey) + } + + console.log('[切换账号] 检查数据库连接状态') + console.log('[切换账号] 数据库连接状态:', isDbConnected) + if (isDbConnected) { + console.log('[切换账号] 关闭数据库连接') + await window.electronAPI.chat.close() + } + + console.log('[切换账号] 清除缓存') + window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + clearAnalyticsStoreCache() + resetChatStore() + + console.log('[切换账号] 触发 wxid-changed 事件') + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } })) + + console.log('[切换账号] 切换成功') + setShowSwitchAccountDialog(false) + } catch (error) { + console.error('[切换账号] 失败:', error) + window.alert('切换账号失败,请稍后重试') + } finally { + setIsSwitchingAccount(false) + } } const openSettingsFromAccountMenu = () => { @@ -291,167 +376,128 @@ function Sidebar({ collapsed }: SidebarProps) { }) } - const handleConfirmClearAccountData = async () => { - if (!canConfirmClear || isClearingAccountData) return - setIsClearingAccountData(true) - try { - const result = await window.electronAPI.chat.clearCurrentAccountData({ - clearCache: shouldClearCacheData, - clearExports: shouldClearExportData - }) - if (!result.success) { - window.alert(result.error || '清理失败,请稍后重试。') - return - } - window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) - setUserProfile({ wxid: '', displayName: '未识别用户' }) - window.dispatchEvent(new Event('wxid-changed')) - - const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : [] - const selectedScopes = [ - shouldClearCacheData ? '缓存数据' : '', - shouldClearExportData ? '导出数据' : '' - ].filter(Boolean) - const detailLines: string[] = [ - `清理范围:${selectedScopes.join('、') || '未选择'}`, - `已清理项目:${removedPaths.length} 项` - ] - if (removedPaths.length > 0) { - detailLines.push('', '清理明细(最多显示 8 项):') - for (const [index, path] of removedPaths.slice(0, 8).entries()) { - detailLines.push(`${index + 1}. ${path}`) - } - if (removedPaths.length > 8) { - detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`) - } - } - if (result.warning) { - detailLines.push('', `注意:${result.warning}`) - } - const followupHint = shouldClearCacheData - ? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。' - : '你可以继续使用当前登录状态,无需重新登录。' - window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`) - resetClearDialogState() - if (shouldClearCacheData) { - window.location.reload() - } - } catch (error) { - console.error('清理账号数据失败:', error) - window.alert('清理失败,请稍后重试。') - } finally { - setIsClearingAccountData(false) - } + const isActive = (path: string) => { + return location.pathname === path || location.pathname.startsWith(`${path}/`) } + const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}` return ( - - {showClearAccountDialog && ( -
!isClearingAccountData && resetClearDialogState()}> -
event.stopPropagation()}> -

清除此账号所有数据

-

- 操作后可将该账户在 weflow 下产生的所有缓存文件、导出文件等彻底清除。 - 清除后必须手动登录微信客户端 weflow 才能再次获取,保障你的数据安全。 -

-
- - + {showSwitchAccountDialog && ( +
!isSwitchingAccount && setShowSwitchAccountDialog(false)}> +
event.stopPropagation()}> +

切换账号

+

选择要切换的微信账号

+
+ {wxidOptions.map((option) => ( + + ))}
-
- - +
+
)} - + ) } diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index 139083c..8c3c9b8 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -3,6 +3,7 @@ background: var(--bg-secondary); display: flex; align-items: center; + justify-content: space-between; padding-left: 16px; padding-right: 16px; border-bottom: 1px solid var(--border-color); @@ -35,6 +36,8 @@ font-size: 15px; font-weight: 500; color: var(--text-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } .title-sidebar-toggle { @@ -57,3 +60,87 @@ color: var(--text-primary); } } + +.title-window-controls { + display: inline-flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} + +.title-window-control-btn { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &.is-close:hover { + background: #e5484d; + color: #fff; + } +} + +.image-controls { + display: flex; + align-items: center; + gap: 8px; + margin-right: auto; + padding-left: 16px; + -webkit-app-region: no-drag; + + button { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &:disabled { + cursor: default; + opacity: 1; + } + + &.live-play-btn.active { + background: rgba(var(--primary-rgb, 76, 132, 255), 0.16); + color: var(--primary, #4c84ff); + } + } + + .scale-text { + min-width: 50px; + text-align: center; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .divider { + width: 1px; + height: 14px; + background: var(--border-color); + margin: 0 4px; + } +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 7b1b4e0..491a7ea 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,17 +1,42 @@ -import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react' import './TitleBar.scss' interface TitleBarProps { title?: string sidebarCollapsed?: boolean onToggleSidebar?: () => void + showWindowControls?: boolean + customControls?: React.ReactNode + showLogo?: boolean } -function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) { +function TitleBar({ + title, + sidebarCollapsed = false, + onToggleSidebar, + showWindowControls = true, + customControls, + showLogo = true +}: TitleBarProps = {}) { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (!showWindowControls) return + + void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => { + setIsMaximized(false) + }) + + return window.electronAPI.window.onMaximizeStateChanged((maximized) => { + setIsMaximized(maximized) + }) + }, [showWindowControls]) + return (
- WeFlow + {showLogo && WeFlow} {title || 'WeFlow'} {onToggleSidebar ? (
+ {customControls} + {showWindowControls ? ( +
+ + + +
+ ) : null}
) } diff --git a/src/components/UpdateDialog.scss b/src/components/UpdateDialog.scss index f12a6d8..a1a4e39 100644 --- a/src/components/UpdateDialog.scss +++ b/src/components/UpdateDialog.scss @@ -14,7 +14,7 @@ .update-dialog { width: 680px; - background: #f5f5f5; + background: var(--bg-secondary, #f5f5f5); border-radius: 24px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); overflow: hidden; @@ -25,7 +25,7 @@ /* Top Section (White/Gradient) */ .dialog-header { - background: #ffffff; + background: var(--bg-primary, #ffffff); padding: 40px 20px 30px; display: flex; flex-direction: column; @@ -41,14 +41,14 @@ left: -50px; width: 200px; height: 200px; - background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); - opacity: 0.8; + background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.5; pointer-events: none; } .version-tag { - background: #f0eee9; - color: #8c7b6e; + background: var(--bg-tertiary, #f0eee9); + color: var(--text-tertiary, #8c7b6e); padding: 4px 16px; border-radius: 12px; font-size: 13px; @@ -60,21 +60,21 @@ h2 { font-size: 32px; font-weight: 800; - color: #333333; + color: var(--text-primary, #333333); margin: 0 0 12px; letter-spacing: -0.5px; } .subtitle { font-size: 15px; - color: #999999; + color: var(--text-secondary, #999999); font-weight: 400; } } /* Content Section (Light Gray) */ .dialog-content { - background: #f2f2f2; + background: var(--bg-tertiary, #f2f2f2); padding: 24px 40px 40px; flex: 1; display: flex; @@ -87,7 +87,7 @@ margin-bottom: 30px; .icon-box { - background: #fbfbfb; // Beige-ish white + background: var(--bg-primary, #fbfbfb); width: 48px; height: 48px; border-radius: 16px; @@ -96,7 +96,7 @@ justify-content: center; margin-right: 20px; flex-shrink: 0; - color: #8c7b6e; + color: var(--text-tertiary, #8c7b6e); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); svg { @@ -107,27 +107,38 @@ .text-box { flex: 1; - h3 { - font-size: 18px; + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary, #333333); font-weight: 700; - color: #333333; - margin: 0 0 8px; + margin: 16px 0 8px; + + &:first-child { + margin-top: 0; + } + } + + h2 { + font-size: 16px; + } + + h3 { + font-size: 15px; } p { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; - margin: 0; + margin: 4px 0; } ul { - margin: 8px 0 0 18px; + margin: 4px 0 0 18px; padding: 0; li { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; } } @@ -142,19 +153,19 @@ justify-content: space-between; margin-bottom: 8px; font-size: 12px; - color: #888; + color: var(--text-secondary, #888); font-weight: 500; } .progress-bar-bg { height: 6px; - background: #e0e0e0; + background: var(--border-color, #e0e0e0); border-radius: 3px; overflow: hidden; .progress-bar-fill { height: 100%; - background: #000000; + background: var(--text-primary, #000000); border-radius: 3px; transition: width 0.3s ease; } @@ -164,7 +175,7 @@ text-align: center; margin-top: 12px; font-size: 13px; - color: #666; + color: var(--text-secondary, #666); } } @@ -175,8 +186,8 @@ .btn-ignore { background: transparent; - color: #666666; - border: 1px solid #d0d0d0; + color: var(--text-secondary, #666666); + border: 1px solid var(--border-color, #d0d0d0); padding: 16px 32px; border-radius: 20px; font-size: 16px; @@ -185,9 +196,9 @@ transition: all 0.2s; &:hover { - background: #f5f5f5; - border-color: #999999; - color: #333333; + background: var(--bg-hover, #f5f5f5); + border-color: var(--text-secondary, #999999); + color: var(--text-primary, #333333); } &:active { @@ -196,11 +207,11 @@ } .btn-update { - background: #000000; - color: #ffffff; + background: var(--text-primary, #000000); + color: var(--bg-primary, #ffffff); border: none; padding: 16px 48px; - border-radius: 20px; // Pill shape + border-radius: 20px; font-size: 16px; font-weight: 600; cursor: pointer; @@ -231,7 +242,7 @@ right: 16px; background: rgba(0, 0, 0, 0.05); border: none; - color: #999; + color: var(--text-secondary, #999); cursor: pointer; width: 32px; height: 32px; @@ -244,7 +255,7 @@ &:hover { background: rgba(0, 0, 0, 0.1); - color: #333; + color: var(--text-primary, #333); transform: rotate(90deg); } } diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index bafdb18..0dce27d 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -89,7 +89,6 @@ const UpdateDialog: React.FC = ({
-

优化

{updateInfo.releaseNotes ? (
) : ( diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 018fbdb..88f77d0 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -209,16 +209,7 @@ function AnnualReportPage() { return (
-

正在加载年份数据(首批)...

-
-

加载方式:{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}

-

状态:{loadStatusText || '正在加载年份数据...'}

-

- 原生耗时:{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '} - 扫表耗时:{formatLoadElapsed(scanElapsedMs)} |{' '} - 总耗时:{formatLoadElapsed(totalElapsedMs)} -

-
+

正在准备年度报告...

) } @@ -264,30 +255,6 @@ function AnnualReportPage() {

年度报告

选择年份,回顾你在微信里的点点滴滴

- {loadedYearCount > 0 && ( -

- {isYearStatusComplete ? ( - <>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)} - ) : ( - <> - 已显示 {loadedYearCount} 个年份,正在补充更多年份 - (已耗时 {formatLoadElapsed(totalElapsedMs)}) - - )} -

- )} -
-

加载方式:{strategyLabel}

-

- 状态: - {loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')} -

-

- 原生耗时:{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '} - 扫表耗时:{formatLoadElapsed(scanElapsedMs)} |{' '} - 总耗时:{formatLoadElapsed(totalElapsedMs)} -

-
@@ -311,7 +278,6 @@ function AnnualReportPage() {
))}
- {renderYearLoadStatus()}
- {renderYearLoadStatus()}
@@ -2966,10 +2967,51 @@ function ChatPage(props: ChatPageProps) { setFilteredSessions([]) return } - const visible = sessions.filter(s => { + + // 检查是否有折叠的群聊 + const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) + const hasFoldedGroups = foldedGroups.length > 0 + + let visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) + + // 如果有折叠的群聊,但列表中没有入口,则插入入口 + if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) { + // 找到最新的折叠消息 + const latestFolded = foldedGroups.reduce((latest, current) => { + const latestTime = latest.sortTimestamp || latest.lastTimestamp + const currentTime = current.sortTimestamp || current.lastTimestamp + return currentTime > latestTime ? current : latest + }) + + const foldEntry: ChatSession = { + username: 'placeholder_foldgroup', + displayName: '折叠的聊天', + summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`, + type: 0, + sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp, + lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp, + lastMsgType: 0, + unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0), + isMuted: false, + isFolded: false + } + + // 按时间戳插入到正确位置 + const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp + const insertIndex = visible.findIndex(s => { + const sTime = s.sortTimestamp || s.lastTimestamp + return sTime < foldTime + }) + if (insertIndex === -1) { + visible.push(foldEntry) + } else { + visible.splice(insertIndex, 0, foldEntry) + } + } + if (!searchKeyword.trim()) { setFilteredSessions(visible) return @@ -3854,12 +3896,6 @@ function ChatPage(props: ChatPageProps) { - {isSessionListSyncing && ( -
- - 同步中 -
- )} {/* 折叠群 header */} @@ -5470,8 +5506,9 @@ function MessageBubble({ let finalImagePath = imageLocalPath let finalLiveVideoPath = imageLiveVideoPath || undefined - // If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer. - if (imageHasUpdate) { + // Every explicit preview click re-runs the forced HD search/decrypt path so + // users don't need to re-enter the session after WeChat materializes a new original image. + if (message.imageMd5 || message.imageDatName) { try { const upgraded = await requestImageDecrypt(true, true) if (upgraded?.success && upgraded.localPath) { @@ -5503,7 +5540,6 @@ function MessageBubble({ void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) }, [ - imageHasUpdate, imageLiveVideoPath, imageLocalPath, imageCacheKey, diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index cc7e86d..35e5a1d 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -891,28 +891,6 @@ function ContactsPage() { -
- 共 {filteredContacts.length} / {contacts.length} 个联系人 - {contactsUpdatedAt && ( - - {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} - - )} - {contacts.length > 0 && ( - - 头像缓存 {avatarCachedCount}/{contacts.length} - {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} - - )} - {isLoading && contacts.length > 0 && ( - 后台同步中... - )} - {avatarEnrichProgress.running && ( - - 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} - - )} -
{exportMode && (
diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss index c1d842d..4b9f48b 100644 --- a/src/pages/ImageWindow.scss +++ b/src/pages/ImageWindow.scss @@ -7,76 +7,6 @@ overflow: hidden; user-select: none; - .title-bar { - height: 40px; - min-height: 40px; - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - padding-right: 140px; // 为原生窗口控件留出空间 - - .window-drag-area { - flex: 1; - height: 100%; - -webkit-app-region: drag; - } - - .title-bar-controls { - display: flex; - align-items: center; - gap: 8px; - -webkit-app-region: no-drag; - margin-right: 16px; - - button { - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 6px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - &:hover { - background: var(--bg-tertiary); - color: var(--text-primary); - } - - &:disabled { - cursor: default; - opacity: 1; - } - - &.live-play-btn { - &.active { - background: rgba(var(--primary-rgb, 76, 132, 255), 0.16); - color: var(--primary, #4c84ff); - } - } - } - - .scale-text { - min-width: 50px; - text-align: center; - color: var(--text-secondary); - font-size: 12px; - font-variant-numeric: tabular-nums; - } - - .divider { - width: 1px; - height: 14px; - background: var(--border-color); - margin: 0 4px; - } - } - } - .image-viewport { flex: 1; display: flex; diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx index e6b2e5d..6b9777e 100644 --- a/src/pages/ImageWindow.tsx +++ b/src/pages/ImageWindow.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' import { LivePhotoIcon } from '../components/LivePhotoIcon' +import TitleBar from '../components/TitleBar' import './ImageWindow.scss' export default function ImageWindow() { @@ -207,31 +208,35 @@ export default function ImageWindow() { return (
-
-
-
- {hasLiveVideo && ( - <> - -
- - )} - - {Math.round(displayScale * 100)}% - -
- - -
-
+ + {hasLiveVideo && ( + <> + +
+ + )} + + {Math.round(displayScale * 100)}% + +
+ + +
+ } + />
>>({}) // 安全设置 state @@ -203,7 +204,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (!onClose) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose() + handleClose() } } document.addEventListener('keydown', handleKeyDown) @@ -445,6 +446,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setTimeout(() => setMessage(null), 3000) } + const handleClose = () => { + if (!onClose) return + setIsClosing(true) + setTimeout(() => { + onClose() + }, 200) + } + type WxidKeys = { decryptKey: string imageXorKey: number | null @@ -888,6 +897,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const handleClearLog = async () => { + const confirmed = window.confirm('确定清空 wcdb.log 吗?') + if (!confirmed) return + try { + const result = await window.electronAPI.log.clear() + if (!result.success) { + showMessage(result.error || '清空日志失败', false) + return + } + showMessage('日志已清空', true) + } catch (e: any) { + showMessage(`清空日志失败: ${e}`, false) + } + } + const handleClearAnalyticsCache = async () => { if (isClearingCache) return setIsClearingAnalyticsCache(true) @@ -1370,15 +1394,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid })) }} /> -
- ⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。 -
- -
{isFetchingImageKey ? ( @@ -1390,7 +1411,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) : ( imageKeyStatus &&
{imageKeyStatus}
)} - 内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击 + 优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)
@@ -1421,6 +1442,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { +
@@ -2037,7 +2061,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {isCheckingUpdate ? '检查中...' : '检查更新'} - )} @@ -2076,8 +2099,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) return ( -
onClose?.()}> -
event.stopPropagation()}> +
+
event.stopPropagation()}> {message &&
{message.text}
} {/* 多账号选择对话框 */} @@ -2116,7 +2139,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {isTesting ? '测试中...' : '测试连接'} {onClose && ( - )} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 9c1d62c..26311e0 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -780,9 +780,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {currentStep.id === 'image' && (
-
- ⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。 -
@@ -795,11 +792,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
- -
@@ -813,7 +810,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { imageKeyStatus &&
{imageKeyStatus}
)} -
内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击
+
优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)
)}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 72aaa57..efe7735 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,8 @@ export interface ElectronAPI { window: { minimize: () => void maximize: () => void + isMaximized: () => Promise + onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void close: () => void openAgreementWindow: () => Promise completeOnboarding: () => Promise @@ -67,6 +69,7 @@ export interface ElectronAPI { log: { getPath: () => Promise read: () => Promise<{ success: boolean; content?: string; error?: string }> + clear: () => Promise<{ success: boolean; error?: string }> debug: (data: any) => void } diagnostics: {