朋友圈支持定位解析;导出时表情包支持语义化补充导出

This commit is contained in:
cc
2026-03-21 14:50:40 +08:00
parent 262b3622dd
commit 2604be38f0
15 changed files with 832 additions and 59 deletions

View File

@@ -45,6 +45,8 @@ jobs:
- name: Package and Publish macOS arm64 (unsigned DMG) - name: Package and Publish macOS arm64 (unsigned DMG)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: | run: |
npx electron-builder --mac dmg --arm64 --publish always npx electron-builder --mac dmg --arm64 --publish always
@@ -82,6 +84,8 @@ jobs:
- name: Package and Publish Linux - name: Package and Publish Linux
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
run: | run: |
npx electron-builder --linux --publish always npx electron-builder --linux --publish always
@@ -118,6 +122,8 @@ jobs:
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
run: | run: |
npx electron-builder --publish always npx electron-builder --publish always

View File

@@ -110,6 +110,11 @@ npm run build
打包产物在 `release` 目录下。 打包产物在 `release` 目录下。
> [!IMPORTANT]
> 发布版打包已启用签名清单注入:`electron-builder` 的 `afterPack` 会读取 `WF_SIGN_PRIVATE_KEY`Ed25519 私钥,支持 PEM 或 `PKCS8 DER Base64`)并生成 `resources/.wf_manifest.json` + `resources/.wf_manifest.sig`。
> GitHub Release Workflow 需要在 **Settings → Secrets and variables → Actions → Repository secrets** 配置 `WF_SIGN_PRIVATE_KEY`,否则构建会失败(不要填到 Environment secrets
> 可使用 `node scripts/generate-signing-key.cjs` 生成密钥;脚本会同时输出可直接粘贴到 `wcdb/wcdb_api.cpp` 的公钥 XOR 数组。
## 致谢 ## 致谢

View File

@@ -265,6 +265,13 @@ class ExportService {
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
private readonly mediaFileCacheMaxFiles = 120000 private readonly mediaFileCacheMaxFiles = 120000
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
private emojiCaptionCache = new Map<string, string | null>()
private emojiCaptionPending = new Map<string, Promise<string | null>>()
private emojiMd5ByCdnCache = new Map<string, string | null>()
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
private emoticonDbPathCache: string | null = null
private emoticonDbPathCacheToken = ''
private readonly emojiCaptionLookupConcurrency = 8
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -915,7 +922,7 @@ class ExportService {
private shouldDecodeMessageContentInFastMode(localType: number): boolean { private shouldDecodeMessageContentInFastMode(localType: number): boolean {
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
return false return false
} }
return true return true
@@ -989,6 +996,292 @@ class ExportService {
return `${localType}_${this.getStableMessageKey(msg)}` return `${localType}_${this.getStableMessageKey(msg)}`
} }
private normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase()
if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
return md5
}
private normalizeEmojiCaption(value: unknown): string | null {
const caption = String(value || '').trim()
if (!caption) return null
return caption
}
private formatEmojiSemanticText(caption?: string | null): string {
const normalizedCaption = this.normalizeEmojiCaption(caption)
if (!normalizedCaption) return '[表情包]'
return `[表情包:${normalizedCaption}]`
}
private extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const keyedMatch =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
}
private normalizeEmojiCdnUrl(value: unknown): string | undefined {
let url = String(value || '').trim()
if (!url) return undefined
url = url.replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch {
// keep original URL if decoding fails
}
return url.trim() || undefined
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) {
return this.emoticonDbPathCache
}
this.emoticonDbPathCacheToken = token
this.emoticonDbPathCache = null
const dbStoragePath =
this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
this.resolveDbStoragePathForExport(dbPath, rawWxid)
if (!dbStoragePath) return null
const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db')
if (fs.existsSync(strictPath)) {
this.emoticonDbPathCache = strictPath
return strictPath
}
return null
}
private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
return normalized
}
const direct = path.join(normalized, 'db_storage')
if (fs.existsSync(direct)) {
return direct
}
if (!wxid) return null
const viaWxid = path.join(normalized, wxid, 'db_storage')
if (fs.existsSync(viaWxid)) {
return viaWxid
}
try {
const entries = fs.readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = path.join(normalized, entry)
try {
if (!fs.statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = path.join(normalized, entry, 'db_storage')
if (fs.existsSync(candidate)) {
return candidate
}
}
} catch {
// keep null
}
return null
}
private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
const emoticonDbPath = this.resolveStrictEmoticonDbPath()
if (!emoticonDbPath) return null
const candidates = Array.from(new Set([
cdnUrl,
cdnUrl.replace(/&/g, '&amp;')
]))
for (const candidate of candidates) {
const escaped = candidate.replace(/'/g, "''")
const result = await wcdbService.execQuery(
'message',
emoticonDbPath,
`SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
)
const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
if (md5) return md5
}
return null
}
private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
}
const pending = this.emojiMd5ByCdnPending.get(cdnUrl)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
} catch {
return null
}
})()
this.emojiMd5ByCdnPending.set(cdnUrl, task)
try {
const md5 = await task
this.emojiMd5ByCdnCache.set(cdnUrl, md5)
return md5
} finally {
this.emojiMd5ByCdnPending.delete(cdnUrl)
}
}
private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
const md5 = this.normalizeEmojiMd5(md5Raw)
if (!md5) return null
if (this.emojiCaptionCache.has(md5)) {
return this.emojiCaptionCache.get(md5) ?? null
}
const pending = this.emojiCaptionPending.get(md5)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
if (nativeResult.success) {
const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption)
if (nativeCaption) return nativeCaption
}
} catch {
// ignore and return null
}
return null
})()
this.emojiCaptionPending.set(md5, task)
try {
const caption = await task
if (caption) {
this.emojiCaptionCache.set(md5, caption)
} else {
this.emojiCaptionCache.delete(md5)
}
return caption
} finally {
this.emojiCaptionPending.delete(md5)
}
}
private async hydrateEmojiCaptionsForMessages(
sessionId: string,
messages: any[],
control?: ExportTaskControl
): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
// 某些环境下游标行缺失 47 的 md5先按 localId 回填详情再做 caption 查询。
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
const unresolvedByUrl = new Map<string, any[]>()
const uniqueMd5s = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| this.extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
if (normalizedCdnUrl) {
msg.emojiCdnUrl = normalizedCdnUrl
}
if (!normalizedMd5) {
if (normalizedCdnUrl) {
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
bucket.push(msg)
unresolvedByUrl.set(normalizedCdnUrl, bucket)
} else {
msg.emojiMd5 = undefined
msg.emojiCaption = undefined
}
continue
}
msg.emojiMd5 = normalizedMd5
uniqueMd5s.add(normalizedMd5)
}
const unresolvedUrls = Array.from(unresolvedByUrl.keys())
if (unresolvedUrls.length > 0) {
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
if (!resolvedMd5) return
const attached = unresolvedByUrl.get(url) || []
for (const msg of attached) {
msg.emojiMd5 = resolvedMd5
uniqueMd5s.add(resolvedMd5)
}
})
}
const md5List = Array.from(uniqueMd5s)
if (md5List.length > 0) {
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
await this.getEmojiCaptionByMd5(md5)
})
}
let assignIndex = 0
for (const msg of messages) {
if ((assignIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
if (!md5) {
msg.emojiCaption = undefined
continue
}
const caption = this.emojiCaptionCache.get(md5) ?? null
msg.emojiCaption = caption || undefined
}
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -1574,8 +1867,12 @@ class ExportService {
createTime?: number, createTime?: number,
myWxid?: string, myWxid?: string,
senderWxid?: string, senderWxid?: string,
isSend?: boolean isSend?: boolean,
emojiCaption?: string
): string | null { ): string | null {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return null if (!content) return null
const normalizedContent = this.normalizeAppMessageContent(content) const normalizedContent = this.normalizeAppMessageContent(content)
@@ -1601,7 +1898,7 @@ class ExportService {
} }
case 42: return '[名片]' case 42: return '[名片]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return this.formatEmojiSemanticText(emojiCaption)
case 48: { case 48: {
const normalized48 = this.normalizeAppMessageContent(content) const normalized48 = this.normalizeAppMessageContent(content)
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
@@ -1711,7 +2008,8 @@ class ExportService {
voiceTranscript?: string, voiceTranscript?: string,
myWxid?: string, myWxid?: string,
senderWxid?: string, senderWxid?: string,
isSend?: boolean isSend?: boolean,
emojiCaption?: string
): string { ): string {
const safeContent = content || '' const safeContent = content || ''
@@ -1741,6 +2039,9 @@ class ExportService {
const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
return seconds ? `[视频]${seconds}s` : '[视频]' return seconds ? `[视频]${seconds}s` : '[视频]'
} }
if (localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (localType === 48) { if (localType === 48) {
const normalized = this.normalizeAppMessageContent(safeContent) const normalized = this.normalizeAppMessageContent(safeContent)
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
@@ -2481,7 +2782,7 @@ class ExportService {
case 3: return '[图片]' case 3: return '[图片]'
case 34: return '[语音消息]' case 34: return '[语音消息]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return '[表情]'
case 49: case 49:
case 8: return title ? `[文件] ${title}` : '[文件]' case 8: return title ? `[文件] ${title}` : '[文件]'
case 17: return item.chatRecordDesc || title || '[聊天记录]' case 17: return item.chatRecordDesc || title || '[聊天记录]'
@@ -2622,7 +2923,7 @@ class ExportService {
displayContent = '[视频]' displayContent = '[视频]'
break break
case '47': case '47':
displayContent = '[动画表情]' displayContent = '[表情]'
break break
case '49': case '49':
displayContent = '[链接]' displayContent = '[链接]'
@@ -2935,7 +3236,17 @@ class ExportService {
return rendered.join('') return rendered.join('')
} }
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string { private formatHtmlMessageText(
content: string,
localType: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean,
emojiCaption?: string
): string {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return '' if (!content) return ''
if (localType === 1) { if (localType === 1) {
@@ -2943,10 +3254,10 @@ class ExportService {
} }
if (localType === 34) { if (localType === 34) {
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || '' return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
} }
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend) return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
} }
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null { private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
@@ -3487,8 +3798,11 @@ class ExportService {
*/ */
private extractEmojiMd5(content: string): string | undefined { private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content) const match =
return match?.[1] /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
/md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
/<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content)
} }
private extractVideoMd5(content: string): string | undefined { private extractVideoMd5(content: string): string | undefined {
@@ -3777,6 +4091,7 @@ class ExportService {
let locationPoiname: string | undefined let locationPoiname: string | undefined
let locationLabel: string | undefined let locationLabel: string | undefined
let chatRecordList: any[] | undefined let chatRecordList: any[] | undefined
let emojiCaption: string | undefined
if (localType === 48 && content) { if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType) const locationMeta = this.extractLocationMeta(content, localType)
@@ -3788,22 +4103,30 @@ class ExportService {
} }
} }
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}`
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
}
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload)
}
if (collectMode === 'full' || collectMode === 'media-fast') { if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。 // 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
if (localType === 3 && content) { if (localType === 3 && content) {
// 图片消息 // 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content) imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content) imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content) videoMd5 = videoMd5 || this.extractVideoMd5(content)
@@ -3830,6 +4153,7 @@ class ExportService {
imageDatName, imageDatName,
emojiCdnUrl, emojiCdnUrl,
emojiMd5, emojiMd5,
emojiCaption,
videoMd5, videoMd5,
locationLat, locationLat,
locationLng, locationLng,
@@ -3898,7 +4222,7 @@ class ExportService {
const needsBackfill = rows.filter((msg) => { const needsBackfill = rows.filter((msg) => {
if (!targetMediaTypes.has(msg.localType)) return false if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5 if (msg.localType === 43) return !msg.videoMd5
return false return false
}) })
@@ -3915,9 +4239,16 @@ class ExportService {
if (!detail.success || !detail.message) return if (!detail.success || !detail.message) return
const row = detail.message as any const row = detail.message as any
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? '' const rawMessageContent = this.getRowField(row, [
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? '' 'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rawCompressContent = this.getRowField(row, [
'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
]) ?? ''
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}`
if (msg.localType === 3) { if (msg.localType === 3) {
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
@@ -3928,8 +4259,15 @@ class ExportService {
} }
if (msg.localType === 47) { if (msg.localType === 47) {
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content) const emojiMd5 =
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content) this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
this.extractEmojiMd5(content) ||
this.extractEmojiMd5(supplementalPayload) ||
this.extractLooseHexMd5(supplementalPayload)
const emojiCdnUrl =
String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
this.extractEmojiUrl(content) ||
this.extractEmojiUrl(supplementalPayload)
if (emojiMd5) msg.emojiMd5 = emojiMd5 if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return return
@@ -4409,6 +4747,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34) ? allMessages.filter(msg => msg.localType === 34)
: [] : []
@@ -4634,7 +4974,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -4726,7 +5067,7 @@ class ExportService {
break break
case 47: case 47:
recordType = 5 // EMOJI recordType = 5 // EMOJI
recordContent = '[动画表情]' recordContent = '[表情]'
break break
default: default:
recordType = 0 recordType = 0
@@ -4936,6 +5277,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -5114,7 +5457,7 @@ class ExportService {
if (msg.localType === 34 && options.exportVoiceAsText) { if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) { } else if (mediaItem && msg.localType !== 47) {
content = mediaItem.relativePath content = mediaItem.relativePath
} else { } else {
content = this.parseMessageContent( content = this.parseMessageContent(
@@ -5124,7 +5467,8 @@ class ExportService {
undefined, undefined,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -5185,6 +5529,12 @@ class ExportService {
senderAvatarKey: msg.senderUsername senderAvatarKey: msg.senderUsername
} }
if (msg.localType === 47) {
if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
}
const platformMessageId = this.getExportPlatformMessageId(msg) const platformMessageId = this.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId if (platformMessageId) msgObj.platformMessageId = platformMessageId
@@ -5420,6 +5770,9 @@ class ExportService {
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
@@ -5650,6 +6003,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -6007,9 +6362,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6017,7 +6373,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6269,9 +6626,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6279,7 +6637,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
let enrichedContentValue = contentValue let enrichedContentValue = contentValue
@@ -6468,6 +6827,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -6635,9 +6996,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6645,7 +7007,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6828,6 +7191,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>() const senderUsernames = new Set<string>()
let senderScanIndex = 0 let senderScanIndex = 0
for (const msg of collected.rows) { for (const msg of collected.rows) {
@@ -7046,7 +7411,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) || '') ) || '')
const src = this.getWeCloneSource(msg, typeName, mediaItem) const src = this.getWeCloneSource(msg, typeName, mediaItem)
const platformMessageId = this.getExportPlatformMessageId(msg) || '' const platformMessageId = this.getExportPlatformMessageId(msg) || ''
@@ -7255,6 +7621,8 @@ class ExportService {
} }
const totalMessages = collected.rows.length const totalMessages = collected.rows.length
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>() const senderUsernames = new Set<string>()
let senderScanIndex = 0 let senderScanIndex = 0
for (const msg of collected.rows) { for (const msg of collected.rows) {
@@ -7545,12 +7913,13 @@ class ExportService {
msg.localType, msg.localType,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
if (msg.localType === 34 && useVoiceTranscript) { if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} }
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { if (mediaItem && msg.localType === 3) {
textContent = '' textContent = ''
} }
if (this.isTransferExportContent(textContent) && msg.content) { if (this.isTransferExportContent(textContent) && msg.content) {

View File

@@ -27,6 +27,17 @@ export interface SnsMedia {
livePhoto?: SnsLivePhoto livePhoto?: SnsLivePhoto
} }
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost { export interface SnsPost {
id: string id: string
tid?: string // 数据库主键(雪花 ID用于精确删除 tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -39,6 +50,7 @@ export interface SnsPost {
media: SnsMedia[] media: SnsMedia[]
likes: string[] likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
location?: SnsLocation
rawXml?: string rawXml?: string
linkTitle?: string linkTitle?: string
linkUrl?: string linkUrl?: string
@@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
return comments return comments
} }
const decodeXmlText = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
}
class SnsService { class SnsService {
private configService: ConfigService private configService: ConfigService
private contactCache: ContactCacheService private contactCache: ContactCacheService
@@ -647,6 +670,110 @@ class SnsService {
return { media, videoKey } return { media, videoKey }
} }
private toOptionalNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
private normalizeLocation(input: unknown): SnsLocation | undefined {
if (!input || typeof input !== 'object') return undefined
const row = input as Record<string, unknown>
const normalizeText = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined
return this.toOptionalString(decodeXmlText(value))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x)
const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y)
const city = normalizeText(row.city)
const country = normalizeText(row.country)
const poiName = normalizeText(row.poiName ?? row.poiname)
const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress)
const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname)
const label = normalizeText(row.label)
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
}
private parseLocationFromXml(xml: string): SnsLocation | undefined {
if (!xml) return undefined
try {
const locationTagMatch = xml.match(/<location\b([^>]*)>/i)
const locationAttrs = locationTagMatch?.[1] || ''
const readAttr = (name: string): string | undefined => {
if (!locationAttrs) return undefined
const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const readTag = (name: string): string | undefined => {
const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x'))
const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y'))
const city = readAttr('city') || readTag('city')
const country = readAttr('country') || readTag('country')
const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname')
const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress')
const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname')
const label = readAttr('label') || readTag('label')
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
} catch (e) {
console.error('[SnsService] 解析位置 XML 失败:', e)
return undefined
}
}
private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined {
if (!primary && !fallback) return undefined
const merged: SnsLocation = {}
const setValue = <K extends keyof SnsLocation>(key: K, value: SnsLocation[K] | undefined) => {
if (value !== undefined) merged[key] = value
}
setValue('latitude', primary?.latitude ?? fallback?.latitude)
setValue('longitude', primary?.longitude ?? fallback?.longitude)
setValue('city', primary?.city ?? fallback?.city)
setValue('country', primary?.country ?? fallback?.country)
setValue('poiName', primary?.poiName ?? fallback?.poiName)
setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress)
setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName)
setValue('label', primary?.label ?? fallback?.label)
return Object.keys(merged).length > 0 ? merged : undefined
}
private getSnsCacheDir(): string { private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath() const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache') const snsCacheDir = join(cachePath, 'sns_cache')
@@ -948,7 +1075,12 @@ class SnsService {
const enrichedTimeline = result.timeline.map((post: any) => { const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username) const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15 const isVideoPost = post.type === 15
const videoKey = extractVideoKey(post.rawXml || '') const rawXml = post.rawXml || ''
const videoKey = extractVideoKey(rawXml)
const location = this.mergeLocation(
this.normalizeLocation((post as { location?: unknown }).location),
this.parseLocationFromXml(rawXml)
)
const fixedMedia = (post.media || []).map((m: any) => ({ const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost), url: fixSnsUrl(m.url, m.token, isVideoPost),
@@ -971,7 +1103,6 @@ class SnsService {
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || [] const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
const rawXml = post.rawXml || ''
let finalComments: any[] let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
@@ -990,7 +1121,8 @@ class SnsService {
avatarUrl: contact?.avatarUrl, avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username, nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia, media: fixedMedia,
comments: finalComments comments: finalComments,
location
} }
}) })
@@ -1346,6 +1478,7 @@ class SnsService {
})), })),
likes: p.likes, likes: p.likes,
comments: p.comments, comments: p.comments,
location: p.location,
linkTitle: (p as any).linkTitle, linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl linkUrl: (p as any).linkUrl
})) }))
@@ -1397,6 +1530,7 @@ class SnsService {
})), })),
likes: post.likes, likes: post.likes,
comments: post.comments, comments: post.comments,
location: post.location,
likesDetail, likesDetail,
commentsDetail, commentsDetail,
linkTitle: (post as any).linkTitle, linkTitle: (post as any).linkTitle,
@@ -1479,6 +1613,27 @@ class SnsService {
const ch = name.charAt(0) const ch = name.charAt(0)
return escapeHtml(ch || '?') return escapeHtml(ch || '?')
} }
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = '' let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1502,6 +1657,10 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>` ? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>`
: '' : ''
const locationText = resolveLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
const likesHtml = post.likes.length > 0 const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>` ? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
@@ -1524,6 +1683,7 @@ ${avatarHtml}
<div class="body"> <div class="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div> <div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''} ${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${locationHtml}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''} ${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml} ${linkHtml}
${likesHtml} ${likesHtml}
@@ -1559,6 +1719,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px} .nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)} .tm{font-size:12px;color:var(--t3)}
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px} .txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px}
.loc-i{line-height:1.3}
.loc-t{line-height:1.45;word-break:break-word}
/* 媒体网格 */ /* 媒体网格 */
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px} .mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}

View File

@@ -68,6 +68,8 @@ export class WcdbCore {
private wcdbListMediaDbs: any = null private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetEmoticonCaption: any = null
private wcdbGetEmoticonCaptionStrict: any = null
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null private wcdbGetVoiceDataBatch: any = null
@@ -296,6 +298,24 @@ export class WcdbCore {
return candidates[0] || libName return candidates[0] || libName
} }
private formatInitProtectionError(code: number): string {
switch (code) {
case -101: return '安全校验失败:授权已过期(-101'
case -102: return '安全校验失败:关键环境文件缺失(-102'
case -2201: return '安全校验失败:未找到签名清单(-2201'
case -2202: return '安全校验失败:缺少签名文件(-2202'
case -2203: return '安全校验失败:读取签名清单失败(-2203'
case -2204: return '安全校验失败:读取签名文件失败(-2204'
case -2205: return '安全校验失败:签名内容格式无效(-2205'
case -2206: return '安全校验失败:签名清单解析失败(-2206'
case -2207: return '安全校验失败:清单平台与当前平台不匹配(-2207'
case -2208: return '安全校验失败:目标文件哈希读取失败(-2208'
case -2209: return '安全校验失败:目标文件哈希不匹配(-2209'
case -2210: return '安全校验失败:签名无效(-2210'
default: return `安全校验失败(错误码: ${code}`
}
}
private isLogEnabled(): boolean { private isLogEnabled(): boolean {
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志 // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true if (process.env.WCDB_LOG_ENABLED === '1') return true
@@ -621,7 +641,7 @@ export class WcdbCore {
// InitProtection (Added for security) // InitProtection (Added for security)
try { try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径 // 尝试多个可能的资源路径
const resourcePaths = [ const resourcePaths = [
@@ -634,26 +654,29 @@ export class WcdbCore {
].filter(Boolean) ].filter(Boolean)
let protectionOk = false let protectionOk = false
let protectionCode = -1
for (const resPath of resourcePaths) { for (const resPath of resourcePaths) {
try { try {
// protectionCode = Number(this.wcdbInitProtection(resPath))
protectionOk = this.wcdbInitProtection(resPath) if (protectionCode === 0) {
if (protectionOk) { protectionOk = true
//
break break
} }
this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true)
} catch (e) { } catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true)
} }
} }
if (!protectionOk) { if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') lastDllInitError = this.formatInitProtectionError(protectionCode)
// this.writeLog('InitProtection 失败,继续运行') this.writeLog(`[bootstrap] InitProtection failed finalCode=${protectionCode}`, true)
// 不返回 false允许继续运行 return false
} }
} catch (e) { } catch (e) {
// console.warn('InitProtection symbol not found:', e) lastDllInitError = `InitProtection symbol not found: ${String(e)}`
this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true)
return false
} }
// 定义类型 // 定义类型
@@ -852,6 +875,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url)
this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)')
// wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaption = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true)
}
// wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaptionStrict = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true)
}
// wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
@@ -2700,6 +2739,48 @@ export class WcdbCore {
} }
} }
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaption) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败: ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaptionStrict) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败(strict): ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try { try {

View File

@@ -455,6 +455,20 @@ export class WcdbService {
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 }) return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
} }
/**
* 获取表情包释义
*/
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
}
/**
* 获取表情包释义(严格 DLL 接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
}
/** /**
* 列出消息数据库 * 列出消息数据库
*/ */

View File

@@ -170,6 +170,12 @@ if (parentPort) {
case 'getEmoticonCdnUrl': case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break break
case 'getEmoticonCaption':
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
break
case 'getEmoticonCaptionStrict':
result = await core.getEmoticonCaptionStrict(payload.md5)
break
case 'listMessageDbs': case 'listMessageDbs':
result = await core.listMessageDbs() result = await core.listMessageDbs()
break break

View File

@@ -63,6 +63,7 @@
}, },
"build": { "build": {
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"afterPack": "scripts/afterPack-sign-manifest.cjs",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "hicccc77", "owner": "hicccc77",

Binary file not shown.

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react' import React, { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react' import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns' import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar' import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid' import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis' import { getEmojiPath } from 'wechat-emojis'
@@ -134,6 +134,30 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
} }
} }
const buildLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const normalize = (value?: string): string => (
decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim()
)
const primary = [
normalize(location.poiName),
normalize(location.poiAddressName),
normalize(location.label),
normalize(location.poiAddress)
].find(Boolean) || ''
const region = [normalize(location.country), normalize(location.city)]
.filter(Boolean)
.join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false) const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => { const hostname = useMemo(() => {
@@ -254,6 +278,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post) const linkCard = buildLinkCardData(post)
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard const showMediaGrid = post.media.length > 0 && !showLinkCard
@@ -379,6 +404,13 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div> <div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)} )}
{locationText && (
<div className="post-location" title={locationText}>
<MapPin size={14} />
<span className="post-location-text">{locationText}</span>
</div>
)}
{showLinkCard && linkCard && ( {showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} /> <SnsLinkCard card={linkCard} />
)} )}

View File

@@ -1026,7 +1026,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContact(contact), kind: toKindByContact(contact),
wechatId: contact.username, wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username, displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl, avatarUrl: session?.avatarUrl || contact.avatarUrl,
hasSession: Boolean(session) hasSession: Boolean(session)
} as SessionRow } as SessionRow
}) })
@@ -1046,7 +1046,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContactType(session, contact), kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username, wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username, displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl, avatarUrl: session.avatarUrl || contact?.avatarUrl,
hasSession: true hasSession: true
} as SessionRow } as SessionRow
}) })
@@ -5582,6 +5582,45 @@ function ExportPage() {
return map return map
}, [contactsList]) }, [contactsList])
useEffect(() => {
if (!showSessionDetailPanel) return
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return
const mappedSession = sessionRowByUsername.get(sessionId)
const mappedContact = contactByUsername.get(sessionId)
if (!mappedSession && !mappedContact) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const nextDisplayName = mappedSession?.displayName || mappedContact?.displayName || prev.displayName || sessionId
const nextRemark = mappedContact?.remark ?? prev.remark
const nextNickName = mappedContact?.nickname ?? prev.nickName
const nextAlias = mappedContact?.alias ?? prev.alias
const nextAvatarUrl = mappedSession?.avatarUrl || mappedContact?.avatarUrl || prev.avatarUrl
if (
nextDisplayName === prev.displayName &&
nextRemark === prev.remark &&
nextNickName === prev.nickName &&
nextAlias === prev.alias &&
nextAvatarUrl === prev.avatarUrl
) {
return prev
}
return {
...prev,
displayName: nextDisplayName,
remark: nextRemark,
nickName: nextNickName,
alias: nextAlias,
avatarUrl: nextAvatarUrl
}
})
}, [contactByUsername, sessionDetail?.wxid, sessionRowByUsername, showSessionDetailPanel])
const currentSessionExportRecords = useMemo(() => { const currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim() const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[] if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
@@ -5987,7 +6026,11 @@ function ExportPage() {
loadSnsUserPostCounts({ force: true }) loadSnsUserPostCounts({ force: true })
]) ])
if (String(sessionDetail?.wxid || '').trim()) { const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''
if (currentDetailSessionId) {
await loadSessionDetail(currentDetailSessionId)
void loadSessionRelationStats({ forceRefresh: true }) void loadSessionRelationStats({ forceRefresh: true })
} }
}, [ }, [
@@ -5998,11 +6041,13 @@ function ExportPage() {
filteredContacts, filteredContacts,
isSessionCountStageReady, isSessionCountStageReady,
loadContactsList, loadContactsList,
loadSessionDetail,
loadSessionRelationStats, loadSessionRelationStats,
loadSnsStats, loadSnsStats,
loadSnsUserPostCounts, loadSnsUserPostCounts,
resetSessionMutualFriendsLoader, resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker, scheduleSessionMutualFriendsWorker,
showSessionDetailPanel,
sessionDetail?.wxid sessionDetail?.wxid
]) ])

View File

@@ -759,6 +759,26 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.post-location {
display: flex;
align-items: flex-start;
gap: 6px;
margin: -4px 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
svg {
flex-shrink: 0;
margin-top: 1px;
color: var(--text-tertiary);
}
}
.post-location-text {
word-break: break-word;
}
.post-media-container { .post-media-container {
margin-bottom: 12px; margin-bottom: 12px;
} }

View File

@@ -790,6 +790,16 @@ export interface ElectronAPI {
}> }>
likes: Array<string> likes: Array<string>
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }> comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }>
location?: {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
rawXml?: string rawXml?: string
}> }>
error?: string error?: string

View File

@@ -34,6 +34,17 @@ export interface SnsComment {
emojis?: SnsCommentEmoji[] emojis?: SnsCommentEmoji[]
} }
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost { export interface SnsPost {
id: string id: string
tid?: string // 数据库主键(雪花 ID用于精确删除 tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -46,6 +57,7 @@ export interface SnsPost {
media: SnsMedia[] media: SnsMedia[]
likes: string[] likes: string[]
comments: SnsComment[] comments: SnsComment[]
location?: SnsLocation
rawXml?: string rawXml?: string
linkTitle?: string linkTitle?: string
linkUrl?: string linkUrl?: string

View File

@@ -4,6 +4,10 @@ import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer' import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path' import { resolve } from 'path'
const handleElectronOnStart = (options: { reload: () => void }) => {
options.reload()
}
export default defineConfig({ export default defineConfig({
base: './', base: './',
server: { server: {
@@ -23,6 +27,7 @@ export default defineConfig({
electron([ electron([
{ {
entry: 'electron/main.ts', entry: 'electron/main.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -43,6 +48,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/annualReportWorker.ts', entry: 'electron/annualReportWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -61,6 +67,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/dualReportWorker.ts', entry: 'electron/dualReportWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -79,6 +86,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/imageSearchWorker.ts', entry: 'electron/imageSearchWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -93,6 +101,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/wcdbWorker.ts', entry: 'electron/wcdbWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -112,6 +121,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/transcribeWorker.ts', entry: 'electron/transcribeWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -129,6 +139,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/exportWorker.ts', entry: 'electron/exportWorker.ts',
onstart: handleElectronOnStart,
vite: { vite: {
build: { build: {
outDir: 'dist-electron', outDir: 'dist-electron',
@@ -149,9 +160,7 @@ export default defineConfig({
}, },
{ {
entry: 'electron/preload.ts', entry: 'electron/preload.ts',
onstart(options) { onstart: handleElectronOnStart,
options.reload()
},
vite: { vite: {
build: { build: {
outDir: 'dist-electron' outDir: 'dist-electron'