Compare commits

..

28 Commits

Author SHA1 Message Date
cc
49770f9e8d Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 19:49:50 +08:00
cc
e32261d274 修复闪退问题 2026-03-21 19:49:38 +08:00
hicccc77
3c7a63e616 chore: update wcdb_api related resources 2026-03-21 16:45:35 +08:00
hicccc77
ab7a487e78 fix: escape artifactName template vars in PowerShell for arm64 job 2026-03-21 16:31:09 +08:00
hicccc77
f01e2efd3f fix: arm64 Windows installer distinct filename, fix x64 exe asset filter 2026-03-21 16:18:38 +08:00
cc
3f4a4f7581 修复mac端打包 2026-03-21 16:03:58 +08:00
hicccc77
7f78925bd7 fix: correct module filename for linux/darwin in afterPack sign script 2026-03-21 15:57:33 +08:00
cc
8cbd3b9625 Merge branch 'main' into dev 2026-03-21 15:53:45 +08:00
hicccc77
9fac12ce3c feat: add Windows arm64 support (wcdb_api + WCDB DLLs, getDllPath arch detection, release CI) 2026-03-21 15:49:44 +08:00
cc
ee050aa5fa 一些修复与优化 2026-03-21 15:39:35 +08:00
cc
a179f13031 更新弹窗自动过滤下载字段 2026-03-21 15:17:41 +08:00
cc
f3fc5760fc 修复一些打包问题 2026-03-21 15:04:48 +08:00
cc
d4e04a003c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 14:50:43 +08:00
cc
2604be38f0 朋友圈支持定位解析;导出时表情包支持语义化补充导出 2026-03-21 14:50:40 +08:00
H3CoF6
06a10f77ae Merge pull request #514 from H3CoF6/dev
linux版本增加wayland说明
优化一点点页面显示
---

我发现appimage可以用,之前觉得FUSE导致难以操控微信进程的
重新支持appimage,放弃对deb的打包(等appimage的-1006报错修好后彻底放弃)
2026-03-21 03:45:12 +08:00
H3CoF6
73f1355011 feat: 更新action,放弃deb打包转为更方便和兼容的appimage 2026-03-21 03:15:31 +08:00
H3CoF6
659b9f9680 feat: 设置页面wayland说明和缓存目录展示 2026-03-21 03:05:18 +08:00
H3CoF6
539f854dbf feat: 添加wayland检查和消息弹窗位置失效说明 2026-03-21 02:53:03 +08:00
H3CoF6
45d4e74c98 fix: 修复linux打包后无法正常操作进程的问题 2026-03-21 02:14:38 +08:00
H3CoF6
1d0b101352 Merge pull request #511 from H3CoF6/main
fix:修复linux的一些问题
2026-03-21 00:45:50 +08:00
H3CoF6
ed96eeccee Merge remote-tracking branch 'upstream/dev' 2026-03-21 00:27:33 +08:00
H3CoF6
29d49360f5 feat: 新增语音转文字段错误修复提示 2026-03-21 00:17:43 +08:00
cc
849cac6a40 Merge pull request #509 from hicccc77/dev
Dev
2026-03-20 22:40:09 +08:00
cc
262b3622dd 更新文档描述 2026-03-20 22:39:39 +08:00
xuncha
2692ac2408 Merge pull request #507 from BeiChen-CN/main
fix: 修复 HTTP API 导出 Type 49 链接消息异常
2026-03-20 22:35:23 +08:00
cc
c2502a09a9 优化导出速度,提供可选项优化 2026-03-20 21:43:29 +08:00
姜北尘
2ea7c72fc6 fix: 修复 HTTP API 导出 Type 49 链接消息异常
为 HTTP API 导出重新解析 appmsg 子类型,修复公众号链接被误判为 OTHER 的问题,并补齐导出内容中的 `[链接]` 前缀。

Fixes #300
2026-03-20 21:13:25 +08:00
H3CoF6
6f3b60ef2c fix: 修复linux打包后无法拉起wechat的bug 2026-03-20 06:44:03 +08:00
32 changed files with 1842 additions and 178 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,15 +122,56 @@ 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
release-windows-arm64:
runs-on: windows-latest
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 install
- 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 Windows arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
run: |
npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
update-release-notes: update-release-notes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- release-mac-arm64 - release-mac-arm64
- release-linux - release-linux
- release - release
- release-windows-arm64
steps: steps:
- name: Generate release notes with platform download links - name: Generate release notes with platform download links
@@ -147,10 +192,12 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
} }
WINDOWS_ASSET="$(pick_asset "\\.exe$")" WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")" MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
build_link() { build_link() {
local name="$1" local name="$1"
@@ -160,9 +207,11 @@ jobs:
} }
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")" MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")" LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > release_notes.md <<EOF cat > release_notes.md <<EOF
## 更新日志 ## 更新日志
@@ -172,10 +221,12 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc) [点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载 ## 下载
- Windows Win10+: ${WINDOWS_URL:-$RELEASE_PAGE} - Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE} - macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE} - Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE > 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF EOF

View File

@@ -43,9 +43,19 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- HTTP API 接口(供开发者集成) - HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单) - 查看完整能力清单:[详细功能](#详细功能清单)
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.deb``.tar.gz` |
## 快速开始 ## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
## 详细功能清单 ## 详细功能清单
@@ -94,14 +104,8 @@ npm install
# 3. 运行应用(开发模式) # 3. 运行应用(开发模式)
npm run dev npm run dev
# 4. 打包可执行文件
npm run build
``` ```
打包产物在 `release` 目录下。
## 致谢 ## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架

View File

@@ -122,6 +122,67 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null let downloadedHandler: (() => void) | null = null
const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
const merged = (() => {
if (typeof rawReleaseNotes === 'string') {
return rawReleaseNotes
}
if (Array.isArray(rawReleaseNotes)) {
return rawReleaseNotes
.map((item) => {
if (!item || typeof item !== 'object') return ''
const note = (item as { note?: unknown }).note
return typeof note === 'string' ? note : ''
})
.filter(Boolean)
.join('\n\n')
}
return ''
})()
if (!merged.trim()) return ''
// 兼容 electron-updater 直接返回 HTML 的场景
const removeDownloadSectionFromHtml = (input: string): string => {
return input.replace(
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
''
)
}
// 兼容 Markdown 场景Action 最终 release note 模板)
const removeDownloadSectionFromMarkdown = (input: string): string => {
const lines = input.split(/\r?\n/)
const output: string[] = []
let skipDownloadSection = false
for (const line of lines) {
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
if (headingMatch) {
const heading = headingMatch[1].trim().toLowerCase()
if (heading === '下载' || heading === 'download') {
skipDownloadSection = true
continue
}
if (skipDownloadSection) {
skipDownloadSection = false
}
}
if (!skipDownloadSection) {
output.push(line)
}
}
return output.join('\n')
}
const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
.replace(/\n{3,}/g, '\n\n')
.trim()
return cleaned
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -1043,6 +1104,13 @@ function registerIpcHandlers() {
return app.getVersion() return app.getVersion()
}) })
ipcMain.handle('app:checkWayland', async () => {
if (process.platform !== 'linux') return false;
const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase();
return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland');
})
ipcMain.handle('log:getPath', async () => { ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log') return join(app.getPath('userData'), 'logs', 'wcdb.log')
}) })
@@ -1114,7 +1182,7 @@ function registerIpcHandlers() {
return { return {
hasUpdate: true, hasUpdate: true,
version: latestVersion, version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || '' releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
} }
} }
} }
@@ -2567,7 +2635,7 @@ function checkForUpdatesOnStartup() {
// 通知渲染进程有新版本 // 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', { mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion, version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || '' releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
}) })
} }
} }

View File

@@ -63,7 +63,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => { onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable') return () => ipcRenderer.removeAllListeners('app:updateAvailable')
} },
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
}, },
// 日志 // 日志

View File

@@ -6,7 +6,7 @@ import * as https from 'https'
import * as http from 'http' import * as http from 'http'
import * as fzstd from 'fzstd' import * as fzstd from 'fzstd'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
@@ -292,6 +292,7 @@ class ChatService {
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = '' private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>() private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private initFailureDialogShown = false
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -338,6 +339,55 @@ class ChatService {
return true return true
} }
private extractErrorCode(message?: string): number | null {
const text = String(message || '').trim()
if (!text) return null
const match = text.match(/(?:错误码\s*[:]\s*|\()(-?\d{2,6})(?:\)|\b)/)
if (!match) return null
const parsed = Number(match[1])
return Number.isFinite(parsed) ? parsed : null
}
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
return `错误码: ${code}`
}
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
if (!app.isPackaged) return
if (this.initFailureDialogShown) return
const code = this.extractErrorCode(errorMessage)
if (code === null) return
const isSecurityCode =
code === -101 ||
code === -102 ||
code === -2299 ||
code === -2301 ||
code === -2302 ||
code === -1006 ||
(code <= -2201 && code >= -2212)
if (!isSecurityCode) return
this.initFailureDialogShown = true
const detail = [
`错误码: ${code}`
].join('\n')
try {
await dialog.showMessageBox({
type: 'error',
title: 'WeFlow 启动失败',
message: '启动失败,请反馈错误码。',
detail,
buttons: ['确定'],
noLink: true
})
} catch {
// 弹窗失败不阻断主流程
}
}
/** /**
* 连接数据库 * 连接数据库
*/ */
@@ -362,7 +412,9 @@ class ChatService {
const cleanedWxid = this.cleanAccountDirName(wxid) const cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!openOk) { if (!openOk) {
return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' } const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
await this.maybeShowInitFailureDialog(detailedError)
return { success: false, error: detailedError }
} }
this.connected = true this.connected = true
@@ -376,7 +428,7 @@ class ChatService {
return { success: true } return { success: true }
} catch (e) { } catch (e) {
console.error('ChatService: 连接数据库失败:', e) console.error('ChatService: 连接数据库失败:', e)
return { success: false, error: String(e) } return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) }
} }
} }

View File

@@ -34,6 +34,7 @@ interface ConfigSchema {
autoTranscribeVoice: boolean autoTranscribeVoice: boolean
transcribeLanguages: string[] transcribeLanguages: string[]
exportDefaultConcurrency: number exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[] analyticsExcludedUsernames: string[]
// 安全相关 // 安全相关
@@ -106,6 +107,7 @@ export class ConfigService {
autoTranscribeVoice: false, autoTranscribeVoice: false,
transcribeLanguages: ['zh'], transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4, exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [], analyticsExcludedUsernames: [],
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',

View File

@@ -105,6 +105,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -259,12 +260,20 @@ class ExportService {
private mediaFileCacheReadyDirs = new Set<string>() private mediaFileCacheReadyDirs = new Set<string>()
private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>() private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>()
private mediaFileCacheCleanupPending: Promise<void> | null = null private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0 private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
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()
@@ -517,11 +526,13 @@ class ExportService {
private resetMediaRuntimeState(): void { private resetMediaRuntimeState(): void {
this.mediaExportTelemetry = this.createEmptyMediaTelemetry() this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private clearMediaRuntimeState(): void { private clearMediaRuntimeState(): void {
this.mediaExportTelemetry = null this.mediaExportTelemetry = null
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private getMediaTelemetrySnapshot(): Partial<ExportProgress> { private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
@@ -915,7 +926,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 +1000,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 +1871,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 +1902,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 +2012,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 +2043,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 +2786,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 +2927,7 @@ class ExportService {
displayContent = '[视频]' displayContent = '[视频]'
break break
case '47': case '47':
displayContent = '[动画表情]' displayContent = '[表情]'
break break
case '49': case '49':
displayContent = '[链接]' displayContent = '[链接]'
@@ -2935,7 +3240,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 +3258,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 {
@@ -3014,6 +3329,7 @@ class ExportService {
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVideoPoster?: boolean includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string> dirCache?: Set<string>
} }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
@@ -3021,7 +3337,14 @@ class ExportService {
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) const result = await this.exportImage(
msg,
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.imageDeepSearchOnMiss !== false
)
if (result) { if (result) {
} }
return result return result
@@ -3067,7 +3390,8 @@ class ExportService {
sessionId: string, sessionId: string,
mediaRootDir: string, mediaRootDir: string,
mediaRelativePrefix: string, mediaRelativePrefix: string,
dirCache?: Set<string> dirCache?: Set<string>,
imageDeepSearchOnMiss = true
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3084,16 +3408,34 @@ class ExportService {
return null return null
} }
const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId,
imageMd5,
imageDatName,
imageDeepSearchOnMiss
)
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null
}
const result = await imageDecryptService.decryptImage({ const result = await imageDecryptService.decryptImage({
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName,
force: true, // 导出优先高清,失败再回退缩略图 force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
if (!imageDeepSearchOnMiss) {
console.log(`[Export] 未命中 hardlink已关闭缺图深度搜索→ 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
// 尝试获取缩略图 // 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({ const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId, sessionId,
@@ -3114,6 +3456,9 @@ class ExportService {
result.localPath = cachedThumb result.localPath = cachedThumb
} else { } else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null return null
} }
} }
@@ -3487,8 +3832,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 +4125,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 +4137,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 +4187,7 @@ class ExportService {
imageDatName, imageDatName,
emojiCdnUrl, emojiCdnUrl,
emojiMd5, emojiMd5,
emojiCaption,
videoMd5, videoMd5,
locationLat, locationLat,
locationLng, locationLng,
@@ -3898,7 +4256,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 +4273,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 +4293,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 +4781,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)
: [] : []
@@ -4511,6 +4885,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -4634,7 +5009,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -4726,7 +5102,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 +5312,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)
: [] : []
@@ -5010,6 +5388,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -5114,7 +5493,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 +5503,8 @@ class ExportService {
undefined, undefined,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -5185,6 +5565,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 +5806,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 +6039,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)
: [] : []
@@ -5850,6 +6241,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6007,9 +6399,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 +6410,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 +6663,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 +6674,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 +6864,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)
: [] : []
@@ -6551,6 +6949,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6635,9 +7034,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 +7045,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 +7229,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) {
@@ -6916,6 +7319,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7046,7 +7450,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 +7660,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) {
@@ -7334,6 +7741,7 @@ class ExportService {
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7545,12 +7953,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

@@ -1226,7 +1226,7 @@ class HttpService {
* 映射 Type 49 子类型 * 映射 Type 49 子类型
*/ */
private mapType49(msg: Message): number { private mapType49(msg: Message): number {
const xmlType = msg.xmlType const xmlType = this.resolveType49Subtype(msg)
switch (xmlType) { switch (xmlType) {
case '5': // 链接 case '5': // 链接
@@ -1250,10 +1250,97 @@ class HttpService {
} }
} }
private extractType49Subtype(rawContent: string): string {
const content = String(rawContent || '')
if (!content) return ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) {
return typeMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
}
const fallbackMatch = /<type>([\s\S]*?)<\/type>/i.exec(content)
if (fallbackMatch) {
return fallbackMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private resolveType49Subtype(msg: Message): string {
const xmlType = String(msg.xmlType || '').trim()
if (xmlType) return xmlType
const extractedType = this.extractType49Subtype(msg.rawContent)
if (extractedType) return extractedType
switch (msg.appMsgKind) {
case 'official-link':
case 'link':
return '5'
case 'file':
return '6'
case 'chat-record':
return '19'
case 'miniapp':
return '33'
case 'quote':
return '57'
case 'transfer':
return '2000'
case 'red-packet':
return '2001'
case 'music':
return '3'
default:
if (msg.linkUrl) return '5'
if (msg.fileName) return '6'
return ''
}
}
private getType49Content(msg: Message): string {
const subtype = this.resolveType49Subtype(msg)
const title = msg.linkTitle || msg.fileName || ''
switch (subtype) {
case '5':
case '49':
return title ? `[链接] ${title}` : '[链接]'
case '6':
return title ? `[文件] ${title}` : '[文件]'
case '19':
return title ? `[聊天记录] ${title}` : '[聊天记录]'
case '33':
case '36':
return title ? `[小程序] ${title}` : '[小程序]'
case '57':
return msg.parsedContent || title || '[引用消息]'
case '2000':
return title ? `[转账] ${title}` : '[转账]'
case '2001':
return title ? `[红包] ${title}` : '[红包]'
case '3':
return title ? `[音乐] ${title}` : '[音乐]'
default:
return msg.parsedContent || title || '[消息]'
}
}
/** /**
* 获取消息内容 * 获取消息内容
*/ */
private getMessageContent(msg: Message): string | null { private getMessageContent(msg: Message): string | null {
if (msg.localType === 49) {
return this.getType49Content(msg)
}
// 优先使用已解析的内容 // 优先使用已解析的内容
if (msg.parsedContent) { if (msg.parsedContent) {
return msg.parsedContent return msg.parsedContent
@@ -1276,7 +1363,7 @@ class HttpService {
case 48: case 48:
return '[位置]' return '[位置]'
case 49: case 49:
return msg.linkTitle || msg.fileName || '[消息]' return this.getType49Content(msg)
default: default:
return msg.rawContent || null return msg.rawContent || null
} }

View File

@@ -64,6 +64,7 @@ type CachedImagePayload = {
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
force?: boolean force?: boolean
hardlinkOnly?: boolean
} }
export class ImageDecryptService { export class ImageDecryptService {
@@ -158,7 +159,9 @@ export class ImageDecryptService {
} }
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> { async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
await this.ensureCacheIndexed() if (!payload.hardlinkOnly) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload) const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0] const cacheKey = cacheKeys[0]
if (!cacheKey) { if (!cacheKey) {
@@ -180,14 +183,16 @@ export class ImageDecryptService {
} }
} }
for (const key of cacheKeys) { if (!payload.hardlinkOnly) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId) for (const key of cacheKeys) {
if (!existingHd || this.isThumbnailPath(existingHd)) continue const existingHd = this.findCachedOutput(key, true, payload.sessionId)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
return { success: true, localPath } this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
}
} }
} }
@@ -255,7 +260,7 @@ export class ImageDecryptService {
payload: DecryptImagePayload, payload: DecryptImagePayload,
cacheKey: string cacheKey: string
): Promise<DecryptResult> { ): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
try { try {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -275,7 +280,11 @@ export class ImageDecryptService {
payload.imageMd5, payload.imageMd5,
payload.imageDatName, payload.imageDatName,
payload.sessionId, payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } {
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
) )
// 如果要求高清图但没找到,直接返回提示 // 如果要求高清图但没找到,直接返回提示
@@ -298,18 +307,20 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} }
// 查找已缓存的解密文件 // 查找已缓存的解密文件hardlink-only 模式下跳过全缓存目录扫描)
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) if (!payload.hardlinkOnly) {
if (existing) { const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) if (existing) {
const isHd = this.isHdPath(existing) this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
// 如果要求高清但找到的是缩略图,继续解密高清图 const isHd = this.isHdPath(existing)
if (!(payload.force && !isHd)) { // 如果要求高清但找到的是缩略图,继续解密高清图
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) if (!(payload.force && !isHd)) {
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const isThumb = this.isThumbnailPath(existing) const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) const isThumb = this.isThumbnailPath(existing)
return { success: true, localPath, isThumb } this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
} }
} }
@@ -467,15 +478,17 @@ export class ImageDecryptService {
imageMd5?: string, imageMd5?: string,
imageDatName?: string, imageDatName?: string,
sessionId?: string, sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
): Promise<string | null> { ): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', { this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5, imageMd5,
imageDatName, imageDatName,
allowThumbnail, allowThumbnail,
skipResolvedCache skipResolvedCache,
hardlinkOnly
}) })
if (!skipResolvedCache) { if (!skipResolvedCache) {
@@ -500,7 +513,7 @@ export class ImageDecryptService {
} }
// 1. 通过 MD5 快速定位 (MsgAttach 目录) // 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) { if (!hardlinkOnly && allowThumbnail && imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
@@ -510,7 +523,7 @@ export class ImageDecryptService {
} }
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位 // 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res if (res) return res
} }
@@ -587,6 +600,11 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
if (hardlinkOnly) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
return null
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) { if (!allowThumbnail) {
return null return null

View File

@@ -1,7 +1,7 @@
import { app } from 'electron' import { app } from 'electron'
import { join } from 'path' import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec } from 'child_process' import { execFile, exec, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { createRequire } from 'module'; import { createRequire } from 'module';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -45,33 +45,104 @@ export class KeyServiceLinux {
onStatus?: (message: string, level: number) => void onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> { ): Promise<DbKeyResult> {
try { try {
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
const envWithPath = {
...process.env,
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
};
onStatus?.('正在尝试结束当前微信进程...', 0) onStatus?.('正在尝试结束当前微信进程...', 0)
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {}) console.log('[Debug] 开始执行进程清理逻辑...');
try {
const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath });
console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`);
} catch (err: any) {
// 命令如果没找到进程通常会返回 code 1这也是正常的但我们需要记录下来
console.log(`[Debug] killall 报错或未找到进程: ${err.message}`);
// Fallback: 尝试使用 pkill 兜底
try {
console.log('[Debug] 尝试使用备用命令 pkill...');
await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
console.log('[Debug] pkill 执行完成');
} catch (e: any) {
console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`);
}
}
// 稍微等待进程完全退出 // 稍微等待进程完全退出
await new Promise(r => setTimeout(r, 1000)) await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0) onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &', const cleanEnv = { ...process.env };
'nohup wechat-bin >/dev/null 2>&1 &', delete cleanEnv.ELECTRON_RUN_AS_NODE;
'nohup xwechat >/dev/null 2>&1 &' delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE;
delete cleanEnv.APPDIR;
delete cleanEnv.APPIMAGE;
const wechatBins = [
'wechat',
'wechat-bin',
'xwechat',
'/opt/wechat/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat'
] ]
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
for (const binName of wechatBins) {
try {
const child = spawn(binName, [], {
detached: true,
stdio: 'ignore',
env: cleanEnv
});
child.on('error', (err) => {
console.log(`[Debug] 拉起 ${binName} 失败:`, err.message);
});
child.unref();
console.log(`[Debug] 尝试拉起 ${binName} 完毕`);
} catch (e: any) {
console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message);
}
}
onStatus?.('等待微信进程出现...', 0) onStatus?.('等待微信进程出现...', 0)
let pid = 0 let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒 for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000)) await new Promise(r => setTimeout(r, 1000))
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p) try {
if (pids.length > 0) { const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
pid = parseInt(pids[0], 10) const pids = stdout.trim().split(/\s+/).filter(p => p);
break if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`);
break;
}
} catch (err: any) {
console.log(`[Debug] 第 ${i + 1}pidof 失败: ${err.message.split('\n')[0]}`);
// Fallback: 使用 pgrep 兜底
try {
const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
const pids = pgrepOut.trim().split(/\s+/).filter(p => p);
if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`);
break;
}
} catch (e: any) {
console.log(`[Debug] 第 ${i + 1}pgrep 也失败: ${e.message.split('\n')[0]}`);
}
} }
} }
if (!pid) { if (!pid) {
const err = '未能自动启动微信,手动启动并登录。' const err = '未能自动启动微信,或获取PID失败请查看控制台日志或手动启动并登录。'
onStatus?.(err, 2) onStatus?.(err, 2)
return { success: false, error: err } return { success: false, error: err }
} }
@@ -82,6 +153,7 @@ export class KeyServiceLinux {
return await this.getDbKey(pid, onStatus) return await this.getDbKey(pid, onStatus)
} catch (err: any) { } catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2) onStatus?.(errMsg, 2)
return { success: false, error: errMsg } return { success: false, error: errMsg }

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

@@ -273,8 +273,20 @@ export class VoiceTranscribeService {
}) })
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) })) worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => { worker.on('exit', (code: number | null, signal: string | null) => {
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` }) if (code === null || signal === 'SIGSEGV') {
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
resolve({
success: false,
error: 'SEGFAULT_ERROR'
});
return;
}
if (code !== 0) {
resolve({ success: false, error: `Worker exited with code ${code}` });
}
}) })
} catch (error) { } catch (error) {

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
@@ -124,6 +126,10 @@ export class WcdbCore {
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
} }
getLastInitError(): string | null {
return lastDllInitError
}
setLogEnabled(enabled: boolean): void { setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled this.logEnabled = enabled
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
@@ -264,8 +270,9 @@ export class WcdbCore {
private getDllPath(): string { private getDllPath(): string {
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : '' const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const envDllPath = process.env.WCDB_DLL_PATH const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) { if (envDllPath && envDllPath.length > 0) {
@@ -296,6 +303,10 @@ export class WcdbCore {
return candidates[0] || libName return candidates[0] || libName
} }
private formatInitProtectionError(code: number): string {
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
@@ -617,11 +628,13 @@ export class WcdbCore {
} }
} }
this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true)
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
this.writeLog('[bootstrap] koffi.load ok', true)
// 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 +647,40 @@ export class WcdbCore {
].filter(Boolean) ].filter(Boolean)
let protectionOk = false let protectionOk = false
let protectionCode = -1
let bestFailCode: number | null = null
const scoreFailCode = (code: number): number => {
if (code >= -2212 && code <= -2201) return 0 // manifest/signature/hash failures
if (code === -102 || code === -101 || code === -1006) return 1
return 2
}
for (const resPath of resourcePaths) { for (const resPath of resourcePaths) {
try { try {
// this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true)
protectionOk = this.wcdbInitProtection(resPath) protectionCode = Number(this.wcdbInitProtection(resPath))
if (protectionOk) { if (protectionCode === 0) {
// protectionOk = true
break break
} }
if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) {
bestFailCode = protectionCode
}
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 - 继续运行但可能不稳定') const finalCode = bestFailCode ?? protectionCode
// this.writeLog('InitProtection 失败,继续运行') lastDllInitError = this.formatInitProtectionError(finalCode)
// 不返回 false允许继续运行 this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true)
return false
} }
} catch (e) { } catch (e) {
// console.warn('InitProtection symbol not found:', e) lastDllInitError = this.formatInitProtectionError(-2301)
this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true)
return false
} }
// 定义类型 // 定义类型
@@ -852,6 +879,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)')
@@ -1055,7 +1098,7 @@ export class WcdbCore {
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult) console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}` lastDllInitError = this.formatInitProtectionError(initResult)
return false return false
} }
@@ -1066,14 +1109,7 @@ export class WcdbCore {
const errorMsg = e instanceof Error ? e.message : String(e) const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg) console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg lastDllInitError = this.formatInitProtectionError(-2302)
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
return false return false
} }
} }
@@ -1100,8 +1136,7 @@ export class WcdbCore {
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) { if (!initOk) {
// 返回更详细的错误信息,帮助用户诊断问题 const detailedError = lastDllInitError || this.formatInitProtectionError(-2303)
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError } return { success: false, error: detailedError }
} }
} }
@@ -1111,7 +1146,7 @@ export class WcdbCore {
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) { if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: `数据库目录不存在: ${dbPath}` } return { success: false, error: this.formatInitProtectionError(-3001) }
} }
// 递归查找 session.db // 递归查找 session.db
@@ -1119,7 +1154,7 @@ export class WcdbCore {
this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) { if (!sessionDbPath) {
return { success: false, error: `未找到 session.db 文件` } return { success: false, error: this.formatInitProtectionError(-3002) }
} }
// 分配输出参数内存 // 分配输出参数内存
@@ -1128,17 +1163,13 @@ export class WcdbCore {
if (result !== 0) { if (result !== 0) {
await this.printLogs() await this.printLogs()
let errorMsg = '数据库打开失败'
if (result === -1) errorMsg = '参数错误'
else if (result === -2) errorMsg = '密钥错误'
else if (result === -3) errorMsg = '数据库打开失败'
this.writeLog(`testConnection openAccount failed code=${result}`) this.writeLog(`testConnection openAccount failed code=${result}`)
return { success: false, error: `${errorMsg} (错误码: ${result})` } return { success: false, error: this.formatInitProtectionError(result) }
} }
const tempHandle = handleOut[0] const tempHandle = handleOut[0]
if (tempHandle <= 0) { if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' } return { success: false, error: this.formatInitProtectionError(-3003) }
} }
// 测试成功:使用 shutdown 清理资源(包括测试句柄) // 测试成功:使用 shutdown 清理资源(包括测试句柄)
@@ -1167,7 +1198,7 @@ export class WcdbCore {
} catch (e) { } catch (e) {
console.error('测试连接异常:', e) console.error('测试连接异常:', e)
this.writeLog(`testConnection exception: ${String(e)}`) this.writeLog(`testConnection exception: ${String(e)}`)
return { success: false, error: String(e) } return { success: false, error: this.formatInitProtectionError(-3004) }
} }
} }
@@ -1359,6 +1390,7 @@ export class WcdbCore {
*/ */
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> { async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
try { try {
lastDllInitError = null
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) return false if (!initOk) return false
@@ -1386,6 +1418,7 @@ export class WcdbCore {
if (!dbStoragePath || !existsSync(dbStoragePath)) { if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath) console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`) this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false return false
} }
@@ -1394,6 +1427,7 @@ export class WcdbCore {
if (!sessionDbPath) { if (!sessionDbPath) {
console.error('未找到 session.db 文件') console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found') this.writeLog('open failed: session.db not found')
lastDllInitError = this.formatInitProtectionError(-3002)
return false return false
} }
@@ -1404,11 +1438,13 @@ export class WcdbCore {
console.error('打开数据库失败:', result) console.error('打开数据库失败:', result)
await this.printLogs() await this.printLogs()
this.writeLog(`open failed: openAccount code=${result}`) this.writeLog(`open failed: openAccount code=${result}`)
lastDllInitError = this.formatInitProtectionError(result)
return false return false
} }
const handle = handleOut[0] const handle = handleOut[0]
if (handle <= 0) { if (handle <= 0) {
lastDllInitError = this.formatInitProtectionError(-3003)
return false return false
} }
@@ -1418,6 +1454,7 @@ export class WcdbCore {
this.currentWxid = wxid this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath this.currentDbStoragePath = dbStoragePath
this.initialized = true this.initialized = true
lastDllInitError = null
if (this.wcdbSetMyWxid && wxid) { if (this.wcdbSetMyWxid && wxid) {
try { try {
this.wcdbSetMyWxid(this.handle, wxid) this.wcdbSetMyWxid(this.handle, wxid)
@@ -1435,6 +1472,7 @@ export class WcdbCore {
} catch (e) { } catch (e) {
console.error('打开数据库异常:', e) console.error('打开数据库异常:', e)
this.writeLog(`open exception: ${String(e)}`) this.writeLog(`open exception: ${String(e)}`)
lastDllInitError = this.formatInitProtectionError(-3004)
return false return false
} }
} }
@@ -2700,6 +2738,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

@@ -164,6 +164,10 @@ export class WcdbService {
return this.callWorker('open', { dbPath, hexKey, wxid }) return this.callWorker('open', { dbPath, hexKey, wxid })
} }
async getLastInitError(): Promise<string | null> {
return this.callWorker('getLastInitError')
}
/** /**
* 关闭数据库连接 * 关闭数据库连接
*/ */
@@ -455,6 +459,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

@@ -37,6 +37,9 @@ if (parentPort) {
case 'open': case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break break
case 'getLastInitError':
result = core.getLastInitError()
break
case 'close': case 'close':
core.close() core.close()
result = { success: true } result = { success: true }
@@ -170,6 +173,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,8 @@
}, },
"build": { "build": {
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"afterPack": "scripts/afterPack-sign-manifest.cjs",
"afterSign": "scripts/afterPack-sign-manifest.cjs",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "hicccc77", "owner": "hicccc77",
@@ -95,6 +97,7 @@
"linux": { "linux": {
"icon": "public/icon.png", "icon": "public/icon.png",
"target": [ "target": [
"appimage",
"deb", "deb",
"tar.gz" "tar.gz"
], ],

BIN
resources/arm64/WCDB.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const MANIFEST_NAME = '.wf_manifest.json';
const SIGNATURE_NAME = '.wf_manifest.sig';
const MODULE_FILENAME = {
win32: 'wcdb_api.dll',
darwin: 'libwcdb_api.dylib',
linux: 'libwcdb_api.so',
};
function readTextIfExists(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
function loadEnvFile(projectDir, fileName) {
const envPath = path.join(projectDir, fileName);
const content = readTextIfExists(envPath);
if (!content) return false;
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (!key || process.env[key] !== undefined) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
return true;
}
function ensureSigningEnv() {
const projectDir = process.cwd();
if (!process.env.WF_SIGN_PRIVATE_KEY) {
loadEnvFile(projectDir, '.env.local');
loadEnvFile(projectDir, '.env');
}
const keyB64 = (process.env.WF_SIGN_PRIVATE_KEY || '').trim();
const required = (process.env.WF_SIGNING_REQUIRED || '').trim() === '1';
if (!keyB64) {
if (required) {
throw new Error(
'WF_SIGN_PRIVATE_KEY is missing (WF_SIGNING_REQUIRED=1). ' +
'Set it in CI Secret or .env.local for local build.',
);
}
return null;
}
return keyB64;
}
function getPlatform(context) {
const raw = (
context?.electronPlatformName ||
context?.packager?.platform?.name ||
process.platform
);
return normalizePlatformTag(raw);
}
function normalizePlatformTag(rawPlatform) {
const p = String(rawPlatform || '').toLowerCase();
if (p === 'darwin' || p === 'mac' || p === 'macos' || p === 'osx') return 'darwin';
if (p === 'win32' || p === 'win' || p === 'windows') return 'win32';
if (p === 'linux') return 'linux';
return p || process.platform;
}
function getProductFilename(context) {
return (
context?.packager?.appInfo?.productFilename ||
context?.packager?.config?.productName ||
'WeFlow'
);
}
function resolveMacAppBundle(appOutDir, productFilename) {
const candidates = [];
if (String(appOutDir).toLowerCase().endsWith('.app')) {
candidates.push(appOutDir);
} else {
candidates.push(path.join(appOutDir, `${productFilename}.app`));
if (fs.existsSync(appOutDir) && fs.statSync(appOutDir).isDirectory()) {
const appDirs = fs
.readdirSync(appOutDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.toLowerCase().endsWith('.app'))
.map((e) => path.join(appOutDir, e.name));
candidates.push(...appDirs);
}
}
for (const bundleDir of candidates) {
const resourcesPath = path.join(bundleDir, 'Contents', 'Resources');
if (fs.existsSync(resourcesPath) && fs.statSync(resourcesPath).isDirectory()) {
return bundleDir;
}
}
return null;
}
function getResourcesDir(appOutDir, platform, productFilename) {
if (platform === 'darwin') {
const bundleDir = resolveMacAppBundle(appOutDir, productFilename);
if (!bundleDir) return path.join(appOutDir, 'resources');
return path.join(bundleDir, 'Contents', 'Resources');
}
return path.join(appOutDir, 'resources');
}
function normalizeRel(baseDir, filePath) {
return path.relative(baseDir, filePath).split(path.sep).join('/');
}
function sha256FileHex(filePath) {
const data = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(data).digest('hex');
}
function findFirstExisting(paths) {
for (const p of paths) {
if (p && fs.existsSync(p) && fs.statSync(p).isFile()) return p;
}
return null;
}
function findExecutablePath({ appOutDir, platform, productFilename, executableName }) {
if (platform === 'win32') {
return findFirstExisting([
path.join(appOutDir, `${productFilename}.exe`),
path.join(appOutDir, `${executableName || ''}.exe`),
]);
}
if (platform === 'darwin') {
const bundleDir = resolveMacAppBundle(appOutDir, productFilename) || appOutDir;
const macOsDir = path.join(bundleDir, 'Contents', 'MacOS');
const preferred = findFirstExisting([path.join(macOsDir, productFilename)]);
if (preferred) return preferred;
if (!fs.existsSync(macOsDir)) return null;
const files = fs
.readdirSync(macOsDir)
.map((name) => path.join(macOsDir, name))
.filter((p) => fs.statSync(p).isFile());
return files[0] || null;
}
return findFirstExisting([
path.join(appOutDir, executableName || ''),
path.join(appOutDir, productFilename),
path.join(appOutDir, productFilename.toLowerCase()),
]);
}
function findByBasenameRecursive(rootDir, basename) {
if (!fs.existsSync(rootDir)) return null;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && entry.name.toLowerCase() === basename.toLowerCase()) {
return full;
}
}
}
return null;
}
function getModulePath(resourcesDir, appOutDir, platform) {
const filename = MODULE_FILENAME[platform] || MODULE_FILENAME[process.platform];
if (!filename) return null;
const direct = findFirstExisting([
path.join(resourcesDir, 'resources', filename),
path.join(resourcesDir, filename),
]);
if (direct) return direct;
const inResources = findByBasenameRecursive(resourcesDir, filename);
if (inResources) return inResources;
return findByBasenameRecursive(appOutDir, filename);
}
function signDetachedEd25519(payloadUtf8, privateKeyDerB64) {
const privateKeyDer = Buffer.from(privateKeyDerB64, 'base64');
const keyObject = crypto.createPrivateKey({
key: privateKeyDer,
format: 'der',
type: 'pkcs8',
});
return crypto.sign(null, Buffer.from(payloadUtf8, 'utf8'), keyObject);
}
module.exports = async function afterPack(context) {
const privateKeyDerB64 = ensureSigningEnv();
if (!privateKeyDerB64) {
console.log('[wf-sign] skip: WF_SIGN_PRIVATE_KEY not provided and signing not required.');
return;
}
const appOutDir = context?.appOutDir;
if (!appOutDir || !fs.existsSync(appOutDir)) {
throw new Error(`[wf-sign] invalid appOutDir: ${String(appOutDir)}`);
}
const platform = String(getPlatform(context)).toLowerCase();
const productFilename = getProductFilename(context);
const executableName = context?.packager?.config?.linux?.executableName || '';
const resourcesDir = getResourcesDir(appOutDir, platform, productFilename);
if (!fs.existsSync(resourcesDir)) {
throw new Error(
`[wf-sign] resources directory not found: ${resourcesDir}; platform=${platform}; appOutDir=${appOutDir}`,
);
}
const exePath = findExecutablePath({
appOutDir,
platform,
productFilename,
executableName,
});
if (!exePath) {
throw new Error(
`[wf-sign] executable not found. platform=${platform}, appOutDir=${appOutDir}, productFilename=${productFilename}`,
);
}
const modulePath = getModulePath(resourcesDir, appOutDir, platform);
if (!modulePath) {
throw new Error(
`[wf-sign] ${MODULE_FILENAME[platform] || 'wcdb_api'} not found under resources: ${resourcesDir}`,
);
}
const manifest = {
schema: 1,
platform,
version: context?.packager?.appInfo?.version || '',
generatedAt: new Date().toISOString(),
targets: [
{
id: 'exe',
path: normalizeRel(resourcesDir, exePath),
sha256: sha256FileHex(exePath),
},
{
id: 'module',
path: normalizeRel(resourcesDir, modulePath),
sha256: sha256FileHex(modulePath),
},
],
};
const payload = `${JSON.stringify(manifest, null, 2)}\n`;
const signature = signDetachedEd25519(payload, privateKeyDerB64).toString('base64');
const manifestPath = path.join(resourcesDir, MANIFEST_NAME);
const signaturePath = path.join(resourcesDir, SIGNATURE_NAME);
fs.writeFileSync(manifestPath, payload, 'utf8');
fs.writeFileSync(signaturePath, `${signature}\n`, 'utf8');
console.log(`[wf-sign] manifest: ${manifestPath}`);
console.log(`[wf-sign] signature: ${signaturePath}`);
console.log(`[wf-sign] exe: ${manifest.targets[0].path}`);
console.log(`[wf-sign] exe.sha256: ${manifest.targets[0].sha256}`);
console.log(`[wf-sign] module: ${manifest.targets[1].path}`);
console.log(`[wf-sign] module.sha256: ${manifest.targets[1].sha256}`);
};

View File

@@ -104,6 +104,44 @@ function App() {
// 数据收集同意状态 // 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
try {
// 防止在非客户端环境报错,先检查 API 是否存在
if (!window.electronAPI?.app?.checkWayland) return
// 通过 configService 检查是否已经弹过窗
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
if (!hasWarned) {
const isWayland = await window.electronAPI.app.checkWayland()
if (isWayland) {
setShowWaylandWarning(true)
}
}
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
// 只有在协议同意之后并且已经进入主应用流程才检查
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
checkWaylandStatus()
}
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
const handleDismissWaylandWarning = async () => {
try {
// 记录到本地配置中,下次不再提示
await window.electronAPI.config.set('waylandWarningShown', true)
} catch (e) {
console.error('保存 Wayland 提示状态失败:', e)
}
setShowWaylandWarning(false)
}
useEffect(() => { useEffect(() => {
if (location.pathname !== '/settings') { if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location settingsBackgroundRef.current = location
@@ -432,6 +470,8 @@ function App() {
checkLock() checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口 // 独立协议窗口
if (isAgreementWindow) { if (isAgreementWindow) {
return <AgreementPage /> return <AgreementPage />
@@ -614,6 +654,33 @@ function App() {
</div> </div>
)} )}
{showWaylandWarning && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2> (Wayland)</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p>使 <strong>Wayland</strong> </p>
<p> Wayland <strong></strong></p>
<p></p>
<br />
<p>使</p>
<p>1. <strong>X11 (Xorg)</strong> </p>
<p>2. (WM/DE) </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */} {/* 更新提示对话框 */}
<UpdateDialog <UpdateDialog
open={showUpdateDialog} open={showUpdateDialog}

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

@@ -7611,6 +7611,12 @@ function MessageBubble({
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([]) const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
const voiceAutoDecryptTriggered = useRef(false) const voiceAutoDecryptTriggered = useRef(false)
const [systemAlert, setSystemAlert] = useState<{
title: string;
message: React.ReactNode;
} | null>(null)
// 转账消息双方名称 // 转账消息双方名称
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined) const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined) const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
@@ -8290,9 +8296,9 @@ function MessageBubble({
} }
const result = await window.electronAPI.chat.getVoiceTranscript( const result = await window.electronAPI.chat.getVoiceTranscript(
session.username, session.username,
String(message.localId), String(message.localId),
message.createTime message.createTime
) )
if (result.success) { if (result.success) {
@@ -8300,6 +8306,21 @@ function MessageBubble({
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText) setVoiceTranscript(transcriptText)
} else { } else {
if (result.error === 'SEGFAULT_ERROR') {
console.warn('[ChatPage] 捕获到语音引擎底层段错误');
setSystemAlert({
title: '引擎崩溃提示',
message: (
<>
(Segmentation Fault)<br /><br />
使 Linux <code>sherpa-onnx</code> ( glibc )
</>
)
});
}
setVoiceTranscriptError(true) setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false voiceTranscriptRequestedRef.current = false
} }
@@ -9699,6 +9720,31 @@ function MessageBubble({
{isSelected && <Check size={14} strokeWidth={3} />} {isSelected && <Check size={14} strokeWidth={3} />}
</div> </div>
)} )}
{systemAlert && createPortal(
<div className="modal-overlay" onClick={() => setSystemAlert(null)} style={{ zIndex: 99999 }}>
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="confirm-icon">
<AlertCircle size={32} color="var(--danger)" />
</div>
<div className="confirm-content">
<h3>{systemAlert.title}</h3>
<p style={{ marginTop: '12px', lineHeight: '1.6', fontSize: '14px', color: 'var(--text-secondary)' }}>
{systemAlert.message}
</p>
</div>
<div className="confirm-actions" style={{ justifyContent: 'center', marginTop: '24px' }}>
<button
className="btn-primary"
onClick={() => setSystemAlert(null)}
style={{ padding: '8px 32px' }}
>
</button>
</div>
</div>
</div>,
document.body
)}
</div> </div>
</> </>
) )

View File

@@ -92,6 +92,7 @@ interface ExportOptions {
txtColumns: string[] txtColumns: string[]
displayNamePreference: DisplayNamePreference displayNamePreference: DisplayNamePreference
exportConcurrency: number exportConcurrency: number
imageDeepSearchOnMiss: boolean
} }
interface SessionRow extends AppChatSession { interface SessionRow extends AppChatSession {
@@ -1026,7 +1027,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 +1047,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
}) })
@@ -1593,6 +1594,7 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'json', format: 'json',
@@ -1611,7 +1613,8 @@ function ExportPage() {
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
displayNamePreference: 'remark', displayNamePreference: 'remark',
exportConcurrency: 2 exportConcurrency: 2,
imageDeepSearchOnMiss: true
}) })
const [exportDialog, setExportDialog] = useState<ExportDialogState>({ const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -2138,7 +2141,7 @@ function ExportPage() {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
let isReady = true let isReady = true
try { try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
configService.getExportPath(), configService.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
@@ -2147,6 +2150,7 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(), configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(), configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(), configService.getExportSessionRecordMap(),
@@ -2183,6 +2187,7 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2215,7 +2220,8 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns, txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
})) }))
} catch (error) { } catch (error) {
isReady = false isReady = false
@@ -3989,7 +3995,8 @@ function ExportPage() {
exportEmojis: exportDefaultMedia.emojis, exportEmojis: exportDefaultMedia.emojis,
exportVoiceAsText: exportDefaultVoiceAsText, exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns, excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
} }
if (payload.scope === 'sns') { if (payload.scope === 'sns') {
@@ -4022,7 +4029,8 @@ function ExportPage() {
exportDefaultAvatars, exportDefaultAvatars,
exportDefaultMedia, exportDefaultMedia,
exportDefaultVoiceAsText, exportDefaultVoiceAsText,
exportDefaultConcurrency exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
]) ])
const closeExportDialog = useCallback(() => { const closeExportDialog = useCallback(() => {
@@ -4241,6 +4249,7 @@ function ExportPage() {
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: options.useAllTime dateRange: options.useAllTime
@@ -4833,6 +4842,8 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency) await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
} }
const openSingleExport = useCallback((session: SessionRow) => { const openSingleExport = useCallback((session: SessionRow) => {
@@ -5582,6 +5593,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 +6037,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 +6052,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
]) ])
@@ -6270,6 +6326,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog const shouldShowMediaSection = !isContentScopeDialog
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns' const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -7986,6 +8046,26 @@ function ExportPage() {
</div> </div>
)} )}
{shouldShowImageDeepSearchToggle && (
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
)}
{isSessionScopeDialog && ( {isSessionScopeDialog && (
<div className="dialog-section"> <div className="dialog-section">
<div className="dialog-switch-row"> <div className="dialog-switch-row">

View File

@@ -175,6 +175,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
const [isWayland, setIsWayland] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
if (window.electronAPI?.app?.checkWayland) {
try {
const wayland = await window.electronAPI.app.checkWayland()
setIsWayland(wayland)
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
}
checkWaylandStatus()
}, [])
// 检查 Hello 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
if (window.PublicKeyCredential) { if (window.PublicKeyCredential) {
@@ -1169,6 +1184,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select"> <div className="custom-select">
<div <div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`} className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -1652,34 +1672,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>
<div className="form-group"> <div className="form-group">
<label> <span className="optional">()</span></label> <label> <span className="optional">()</span></label>
<span className="form-hint">使</span> <span className="form-hint">使</span>
<input <input
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => { onChange={(e) => {
const value = e.target.value const value = e.target.value
setCachePath(value) setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value)) scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}} }}
/> />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button> <div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
<button
className="btn btn-secondary" <code style={{
onClick={async () => { background: 'var(--bg-secondary)',
setCachePath('') padding: '3px 6px',
await configService.setCachePath('') borderRadius: '4px',
}} userSelect: 'all',
> wordBreak: 'break-all',
<RotateCcw size={16} /> marginLeft: '4px'
</button> }}>
{cachePath || (isMac ? '~/Documents/WeFlow' : isLinux ? '~/Documents/WeFlow' : '系统 文档\\WeFlow 目录')}
</code>
</div>
<div className="btn-row" style={{ marginTop: '12px' }}>
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div>
</div> </div>
</div>
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}> <button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>

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

@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -462,6 +463,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
} }
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C' export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> { export async function getExportWriteLayout(): Promise<ExportWriteLayout> {

View File

@@ -61,6 +61,7 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }> ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
} }
notification: { notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void> show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
@@ -790,6 +791,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
@@ -852,6 +863,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
export interface ExportProgress { export interface ExportProgress {

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'