diff --git a/src/main/index.ts b/src/main/index.ts index aa7fad0..2e572f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import './preload-env' +import './preload-env' import { app, shell, @@ -8,10 +8,12 @@ import { clipboard, Menu, Tray, - dialog + dialog, + protocol } from 'electron' import { join } from 'path' -import { existsSync } from 'fs' +import { existsSync, promises as fsPromises } from 'fs' +import { extname } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { WechatDb } from './wechat-db' @@ -72,6 +74,7 @@ import { agentHubService } from './services/agent-hub-service' import { appLogger } from './app-logger' import type { AppLogEntry } from '../shared/app-log' import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service' +import { VideoAssetService } from './video-asset-service' // electron-vite can close the child's stdout/stderr after spawning Electron. // Plain console.error then throws EPIPE on a closed pipe and crashes the IPC @@ -81,6 +84,7 @@ installSafeConsole() let voiceService: VoiceService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null +let videoAssetService: VideoAssetService | null = null const databaseKeyStore = new DatabaseKeyStore() const imageKeyConfigService = new ImageKeyConfigService() const aiProviderService = new AIProviderService() @@ -92,6 +96,13 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png') const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon +protocol.registerSchemesAsPrivileged([ + { + scheme: 'wxe-media', + privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true } + } +]) + // WCDB's Windows runtime checks the host application name during wcdb_init. // Mirroring WeFlow's name unblocks the -1006 init failure on Windows. app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer') @@ -109,8 +120,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } { } } +async function createLocalMediaResponse(request: Request, filePath: string): Promise { + const { size } = await fsPromises.stat(filePath) + const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg' + const commonHeaders = { + 'Accept-Ranges': 'bytes', + 'Content-Type': mimeType, + 'Cache-Control': 'private, max-age=300' + } + const range = request.headers.get('range') + + if (!range) { + const body = + request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath)) + return new Response(body, { + status: 200, + headers: { ...commonHeaders, 'Content-Length': String(size) } + }) + } + + const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim()) + if (!match) { + return new Response(null, { + status: 416, + headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` } + }) + } + const start = Number(match[1]) + const requestedEnd = match[2] ? Number(match[2]) : size - 1 + const end = Math.min(requestedEnd, size - 1) + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) { + return new Response(null, { + status: 416, + headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` } + }) + } + + const length = end - start + 1 + let body: Buffer | null = null + if (request.method !== 'HEAD') { + const handle = await fsPromises.open(filePath, 'r') + try { + body = Buffer.allocUnsafe(length) + await handle.read(body, 0, length, start) + } finally { + await handle.close() + } + } + return new Response(body ? Uint8Array.from(body) : null, { + status: 206, + headers: { + ...commonHeaders, + 'Content-Length': String(length), + 'Content-Range': `bytes ${start}-${end}/${size}` + } + }) +} + function createWindow(): void { - // 鍒涘缓娴忚鍣ㄧ獥鍙? + // 创建浏览器窗口 const mainWindow = new BrowserWindow({ width: 1400, height: 800, @@ -132,8 +200,8 @@ function createWindow(): void { return { action: 'deny' } }) - // 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR - // 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢 + // 基于 electron-vite CLI 的渲染器热更新 + // 加载开发环境的远程 URL,或生产环境的本地 HTML 文件 if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -141,9 +209,20 @@ function createWindow(): void { } } -// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶 -// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢? +// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法 +// 某些 API 只能在此事件发生后使用 app.whenReady().then(async () => { + protocol.handle('wxe-media', async (request) => { + const token = new URL(request.url).pathname.replace(/^\/+/, '') + const filePath = videoAssetService?.pathForToken(token) + if (!filePath) return new Response('Not found', { status: 404 }) + try { + return await createLocalMediaResponse(request, filePath) + } catch (error) { + console.warn('[Video] local media request failed:', error) + return new Response('Media unavailable', { status: 500 }) + } + }) console.log(`WechatExplorer main build: ${BUILD_MARK}`) appLogger.write({ level: 'info', @@ -181,14 +260,14 @@ app.whenReady().then(async () => { console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError) } - // 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID + // 设置应用程序用户模型 ID electronApp.setAppUserModelId('com.wechatexplorer.app') if (process.platform === 'darwin') app.dock?.setIcon(appIconPath) - // 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools - // 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R - // 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + // 开发环境中默认使用 F12 打开或关闭 DevTools + // 生产环境中忽略 CommandOrControl + R + // 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) @@ -254,6 +333,7 @@ app.whenReady().then(async () => { }, 0) voiceService = new VoiceService(wcdb4Client) stickerService = new StickerService(wcdb4Client) + videoAssetService = new VideoAssetService(wcdb4Client) const monitoring = wcdb4Client.startMonitor((type, json) => { wcdb4Client.invalidateSessionCache() recallArchiveMonitor?.handleDatabaseChange(json) @@ -657,6 +737,15 @@ app.whenReady().then(async () => { return stickerService.resolveSticker(cdnUrl, md5) }) + ipcMain.handle('db:getVideo', async (_, hashes: string[]) => { + if (!videoAssetService) { + const client = chat.getChatDb()?.getWcdb4Client() + if (!client) return { success: false, error: '数据库尚未连接' } + videoAssetService = new VideoAssetService(client) + } + return videoAssetService.resolve(Array.isArray(hashes) ? hashes : []) + }) + // -------- Settings & API service -------- ipcMain.handle('settings:get', () => ({ @@ -786,7 +875,7 @@ app.whenReady().then(async () => { createWindow() - // 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗) + // 启动本地 HTTP API(由 settings.apiEnabled 控制) const settings = loadSettings() if (settings.apiEnabled) { await apiServer.start(settings.apiHost, settings.apiPort) @@ -800,15 +889,15 @@ app.whenReady().then(async () => { } app.on('activate', function () { - // 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝 - // 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€? + // 在 macOS 上点击 Dock 图标且没有其他窗口打开时, + // 通常会在应用程序中重新创建一个窗口。 if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) -// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛? -// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛 -// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€? +// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上, +// 应用程序及其菜单栏通常会保持活动状态,直到用户 +// 明确使用 Cmd + Q 退出。 app.on('window-all-closed', () => { if (TRAY_MODE) return if (process.platform !== 'darwin') { diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index f8dfd56..caea740 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -24,6 +24,15 @@ type ImageContent = { aeskey?: string encrypVer?: number } +type VideoContent = { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number +} type StickerContent = { type: 'sticker' md5?: string @@ -64,6 +73,7 @@ export type ParsedContent = | ShareContent | VoipContent | ImageContent + | VideoContent | StickerContent | QuoteContent | SystemContent @@ -81,6 +91,8 @@ export function parseMessageContent(content: string, messageType: number): Parse return parseImageMessage(normalized) case 42: return parseCardMessage(normalized) + case 43: + return parseVideoMessage(normalized) case 47: return parseStickerMessage(normalized) case 48: @@ -97,6 +109,19 @@ export function parseMessageContent(content: string, messageType: number): Parse } } +function parseVideoMessage(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5')) + const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5')) + const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5')) + if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content } + + const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined + const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined + const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined + return { type: 'video', md5, newMd5, rawMd5, duration, width, height } +} + function parseSystemMessage(content: string): ParsedContent { const stripped = stripChatroomPrefix(content) const decoded = decodeXmlEntities(stripped) @@ -498,7 +523,10 @@ function extractXmlValue(xml: string, tagName: string): string { } function extractXmlAttribute(xml: string, tagName: string, attrName: string): string { - const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i') + const pattern = new RegExp( + `<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`, + 'i' + ) const match = xml.match(pattern) return match ? match[1].trim() : '' } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 3e3b00f..6d474b0 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -225,7 +225,7 @@ function listSourceMessages( /() + private index: Map | null = null + + constructor(private readonly client: Wcdb4Client) {} + + resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } { + const candidates = Array.from( + new Set( + hashes + .map((value) => + String(value || '') + .trim() + .toLowerCase() + ) + .filter((value) => /^[a-f0-9]{32}$/.test(value)) + ) + ) + if (candidates.length === 0) return { success: false, error: '视频标识为空' } + + const hardlinkDb = path.join( + this.client.getAccountRoot(), + 'db_storage', + 'hardlink', + 'hardlink.db' + ) + const lookupKeys = [...candidates] + if (fs.existsSync(hardlinkDb)) { + for (const hash of candidates) { + const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5 + if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase()) + } + } + + const index = this.getIndex() + for (const key of lookupKeys) { + const asset = index.get(key) || index.get(`${key}_raw`) + if (!asset) continue + return { + success: true, + url: this.createUrl(asset.filePath), + poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined + } + } + return { success: false, error: '本地未找到该视频文件' } + } + + pathForToken(token: string): string | undefined { + const filePath = this.urlTokens.get(token) + if (!filePath || !fs.existsSync(filePath)) return undefined + return filePath + } + + private createUrl(filePath: string): string { + const token = crypto.randomBytes(18).toString('hex') + this.urlTokens.set(token, filePath) + if (this.urlTokens.size > 500) { + const first = this.urlTokens.keys().next().value + if (first) this.urlTokens.delete(first) + } + return `wxe-media://local/${token}` + } + + private getIndex(): Map { + if (this.index) return this.index + const result = new Map() + const root = path.join(this.client.getAccountRoot(), 'msg', 'video') + if (!fs.existsSync(root)) { + this.index = result + return result + } + + for (const month of fs.readdirSync(root)) { + const monthPath = path.join(root, month) + if (!fs.statSync(monthPath).isDirectory()) continue + for (const name of fs.readdirSync(monthPath)) { + const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name) + if (!match) continue + const key = `${match[1].toLowerCase()}${match[2] || ''}` + const fullPath = path.join(monthPath, name) + const existing = result.get(key) || { filePath: '' } + if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath + else if (!existing.posterPath) existing.posterPath = fullPath + result.set(key, existing) + } + } + + for (const [key, asset] of result) { + if (!asset.filePath) result.delete(key) + } + this.index = result + return result + } +} diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index a54e2b2..dcf065f 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -51,6 +51,11 @@ export interface Wcdb4ImageHardlink { [key: string]: unknown } +export interface Wcdb4VideoHardlink { + resolved_md5?: string + [key: string]: unknown +} + type KoffiModule = { load: (libraryPath: string) => KoffiLibrary decode: (ptr: unknown, type: string, length: number) => string @@ -223,6 +228,9 @@ export class Wcdb4Client { private wcdbResolveImageHardlink: | ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number) | null = null + private wcdbResolveVideoHardlink: + | ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number) + | null = null private wcdbGetEmoticonCdnUrl: | ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number) | null = null @@ -1239,6 +1247,23 @@ export class Wcdb4Client { } } + resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null { + if (!this.wcdbResolveVideoHardlink) return null + const normalizedMd5 = String(md5 || '') + .trim() + .toLowerCase() + if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null + + try { + return this.callJson((handle, outJson) => + this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson) + ) + } catch (error) { + console.warn('[WCDB4] resolve video hardlink failed:', error) + return null + } + } + resolveEmoticonCdnUrl(md5: string): string | undefined { if (!this.wcdbGetEmoticonCdnUrl) { console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`) @@ -1452,6 +1477,14 @@ export class Wcdb4Client { this.wcdbResolveImageHardlink = null } + try { + this.wcdbResolveVideoHardlink = lib.func( + 'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)' + ) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbResolveVideoHardlink = null + } + try { this.wcdbGetEmoticonCdnUrl = lib.func( 'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)' diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bdc9810..e304a26 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -48,6 +48,15 @@ export type ParsedContent = | { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string } | { type: 'voip'; duration?: number; status: string; roomType?: number } | { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number } + | { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number + } | { type: 'sticker' md5?: string @@ -151,6 +160,9 @@ declare global { isThumb?: boolean filePath?: string }> + getVideo: ( + hashes: string[] + ) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }> getSticker: ( cdnUrl?: string, md5?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index f366f15..a6e9744 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -59,6 +59,7 @@ const api = { sessionId?: string, options?: { force?: boolean } ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), + getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), exportGroupReport: (request: GroupReportExportRequest) => ipcRenderer.invoke('report:export', request), diff --git a/src/renderer/index.html b/src/renderer/index.html index 8bf886e..43010af 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,7 +7,7 @@ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cde51ef..247fefe 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState } from 'react' import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' import { AppShell } from './components/layout/AppShell' diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 18ca3e7..1d62bb4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1375,6 +1375,54 @@ body { display: none; } +.video-message-bubble { + padding: 0; + overflow: hidden; + background: #101412; + border-color: rgba(0, 0, 0, 0.12); +} + +.wechat-message-row.mine .video-message-bubble { + background: #101412; +} + +.video-message { + position: relative; + width: min(320px, 48vw); + min-height: 140px; + background: #101412; +} + +.video-content { + display: block; + width: 100%; + max-height: 360px; + object-fit: contain; + background: #101412; +} + +.video-duration { + position: absolute; + right: 8px; + top: 8px; + padding: 2px 6px; + border-radius: 8px; + color: #fff; + background: rgba(0, 0, 0, 0.55); + font-size: 11px; + pointer-events: none; +} + +.video-placeholder { + display: grid; + place-items: center; + width: min(260px, 44vw); + min-height: 120px; + padding: 16px; + color: rgba(255, 255, 255, 0.78); + text-align: center; +} + .image-bubble { position: relative; max-width: min(260px, 46vw); diff --git a/src/renderer/src/components/VideoBubble.tsx b/src/renderer/src/components/VideoBubble.tsx new file mode 100644 index 0000000..f503f94 --- /dev/null +++ b/src/renderer/src/components/VideoBubble.tsx @@ -0,0 +1,53 @@ +import { useEffect, useMemo, useState } from 'react' + +interface VideoBubbleProps { + md5?: string + newMd5?: string + rawMd5?: string + duration?: number +} + +export function VideoBubble({ + md5, + newMd5, + rawMd5, + duration +}: VideoBubbleProps): React.ReactElement { + const hashes = useMemo( + () => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)), + [md5, newMd5, rawMd5] + ) + const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({}) + + useEffect(() => { + let cancelled = false + window.api + .getVideo(hashes) + .then((result) => { + if (!cancelled) setMedia(result.success ? result : { error: result.error }) + }) + .catch((error) => { + if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) }) + }) + return () => { + cancelled = true + } + }, [hashes]) + + if (!media.url) { + return
{media.error || '视频加载中…'}
+ } + + return ( +
+
+ ) +} diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 30d6dab..09bf21d 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types' import { ImageBubble } from '../ImageBubble' import { RichMessageBubble } from '../RichMessageBubble' import { VoicePlayer } from '../VoicePlayer' +import { VideoBubble } from '../VideoBubble' import { renderWechatEmojiText } from '../../utils/wechatEmojiText' import { formatMessageTime } from './messageGrouping' @@ -27,6 +28,7 @@ export function MessageBubble({ }: MessageBubbleProps): React.ReactElement { const isVoice = message.type === '语音' const isImage = message.type === '图片' + const isVideo = message.type === '视频' const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type) const hoverTime = formatMessageTime(message) @@ -35,7 +37,7 @@ export function MessageBubble({
{isVoice && message.sessionId ? ( + ) : isVideo && message.contentData && message.contentData.type === 'video' ? ( + ) : isRichMedia && message.contentData ? ( ) : ( diff --git a/src/shared/types.ts b/src/shared/types.ts index 0c6ab26..d32c484 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -54,6 +54,15 @@ type ImageContent = { aeskey?: string encrypVer?: number } +type VideoContent = { + type: 'video' + md5?: string + newMd5?: string + rawMd5?: string + duration?: number + width?: number + height?: number +} type StickerContent = { type: 'sticker' md5?: string @@ -94,6 +103,7 @@ export type ParsedContent = | ShareContent | VoipContent | ImageContent + | VideoContent | StickerContent | QuoteContent | SystemContent