Merge pull request #518 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-03-21 15:53:54 +08:00
committed by GitHub
25 changed files with 1488 additions and 127 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
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
@@ -148,9 +193,11 @@ jobs:
} }
WINDOWS_ASSET="$(pick_asset "\\.exe$")" WINDOWS_ASSET="$(pick_asset "\\.exe$")"
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

@@ -104,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

@@ -267,6 +267,13 @@ class ExportService {
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
private readonly mediaFileCacheMaxFiles = 120000 private readonly mediaFileCacheMaxFiles = 120000
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
private emojiCaptionCache = new Map<string, string | null>()
private emojiCaptionPending = new Map<string, Promise<string | null>>()
private emojiMd5ByCdnCache = new Map<string, string | null>()
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
private emoticonDbPathCache: string | null = null
private emoticonDbPathCacheToken = ''
private readonly emojiCaptionLookupConcurrency = 8
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -919,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
@@ -993,18 +1000,290 @@ class ExportService {
return `${localType}_${this.getStableMessageKey(msg)}` return `${localType}_${this.getStableMessageKey(msg)}`
} }
private getImageMissingRunCacheKey( 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, sessionId: string,
imageMd5: unknown, messages: any[],
imageDatName: unknown, control?: ExportTaskControl
imageDeepSearchOnMiss: boolean ): Promise<void> {
): string | null { if (!Array.isArray(messages) || messages.length === 0) return
const normalizedSessionId = String(sessionId || '').trim()
const normalizedMd5 = String(imageMd5 || '').trim().toLowerCase() // 某些环境下游标行缺失 47 的 md5先按 localId 回填详情再做 caption 查询。
const normalizedDatName = String(imageDatName || '').trim().toLowerCase() await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
if (!normalizedMd5 && !normalizedDatName) return null
const mode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' const unresolvedByUrl = new Map<string, any[]>()
return `${normalizedSessionId}\u001f${normalizedMd5}\u001f${normalizedDatName}\u001f${mode}`
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 }> {
@@ -1592,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)
@@ -1619,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')
@@ -1729,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 || ''
@@ -1759,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')
@@ -2499,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 || '[聊天记录]'
@@ -2640,7 +2927,7 @@ class ExportService {
displayContent = '[视频]' displayContent = '[视频]'
break break
case '47': case '47':
displayContent = '[动画表情]' displayContent = '[表情]'
break break
case '49': case '49':
displayContent = '[链接]' displayContent = '[链接]'
@@ -2953,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) {
@@ -2961,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 {
@@ -3535,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 {
@@ -3825,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)
@@ -3836,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)
@@ -3878,6 +4187,7 @@ class ExportService {
imageDatName, imageDatName,
emojiCdnUrl, emojiCdnUrl,
emojiMd5, emojiMd5,
emojiCaption,
videoMd5, videoMd5,
locationLat, locationLat,
locationLng, locationLng,
@@ -3946,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
}) })
@@ -3963,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)
@@ -3976,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
@@ -4457,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)
: [] : []
@@ -4683,7 +5009,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -4775,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
@@ -4985,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)
: [] : []
@@ -5164,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(
@@ -5174,7 +5503,8 @@ class ExportService {
undefined, undefined,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -5235,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
@@ -5470,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
@@ -5700,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)
: [] : []
@@ -6058,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,
@@ -6068,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
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6320,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,
@@ -6330,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
@@ -6519,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)
: [] : []
@@ -6687,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,
@@ -6697,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
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6880,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) {
@@ -7099,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) || ''
@@ -7308,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) {
@@ -7599,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

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

View File

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

View File

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

View File

@@ -63,6 +63,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.

View File

@@ -0,0 +1,251 @@
#!/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: 'wcdb_api.dylib',
linux: 'wcdb_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) {
return (
context?.electronPlatformName ||
context?.packager?.platform?.name ||
process.platform
);
}
function getProductFilename(context) {
return (
context?.packager?.appInfo?.productFilename ||
context?.packager?.config?.productName ||
'WeFlow'
);
}
function getResourcesDir(appOutDir) {
if (appOutDir.endsWith('.app')) {
return path.join(appOutDir, '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 macOsDir = path.join(appOutDir, '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);
if (!fs.existsSync(resourcesDir)) {
throw new Error(`[wf-sign] resources directory not found: ${resourcesDir}`);
}
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

@@ -1027,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
}) })
@@ -1047,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
}) })
@@ -5593,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[]
@@ -5998,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 })
} }
}, [ }, [
@@ -6009,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
]) ])

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

@@ -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

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'