diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e70a39f..ac6d7ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,8 @@ jobs: - name: Package and Publish macOS arm64 (unsigned DMG) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }} + WF_SIGNING_REQUIRED: "1" CSC_IDENTITY_AUTO_DISCOVERY: "false" run: | npx electron-builder --mac dmg --arm64 --publish always @@ -82,6 +84,8 @@ jobs: - name: Package and Publish Linux env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }} + WF_SIGNING_REQUIRED: "1" run: | npx electron-builder --linux --publish always @@ -118,15 +122,56 @@ jobs: - name: Package and Publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }} + WF_SIGNING_REQUIRED: "1" run: | 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: runs-on: ubuntu-latest needs: - release-mac-arm64 - release-linux - release + - release-windows-arm64 steps: - name: Generate release notes with platform download links @@ -148,9 +193,11 @@ jobs: } 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$")" LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" + LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" build_link() { local name="$1" @@ -160,9 +207,11 @@ jobs: } WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" + WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" MAC_URL="$(build_link "$MAC_ASSET")" LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")" LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" + LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" cat > release_notes.md < 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE EOF diff --git a/README.md b/README.md index 7600b8f..7c4637f 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,8 @@ npm install # 3. 运行应用(开发模式) npm run dev -# 4. 打包可执行文件 -npm run build ``` -打包产物在 `release` 目录下。 - - - ## 致谢 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 diff --git a/electron/main.ts b/electron/main.ts index a60c898..2ebe56b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -122,6 +122,67 @@ let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => 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( + /]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?= { + 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 AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' @@ -1043,6 +1104,13 @@ function registerIpcHandlers() { 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 () => { return join(app.getPath('userData'), 'logs', 'wcdb.log') }) @@ -1114,7 +1182,7 @@ function registerIpcHandlers() { return { hasUpdate: true, version: latestVersion, - releaseNotes: result.updateInfo.releaseNotes as string || '' + releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes) } } } @@ -2567,7 +2635,7 @@ function checkForUpdatesOnStartup() { // 通知渲染进程有新版本 mainWindow.webContents.send('app:updateAvailable', { version: latestVersion, - releaseNotes: result.updateInfo.releaseNotes || '' + releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes) }) } } diff --git a/electron/preload.ts b/electron/preload.ts index f12a272..b1c9c30 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -63,7 +63,8 @@ contextBridge.exposeInMainWorld('electronAPI', { onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => { ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) return () => ipcRenderer.removeAllListeners('app:updateAvailable') - } + }, + checkWayland: () => ipcRenderer.invoke('app:checkWayland'), }, // 日志 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 8af1a10..0a13243 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -267,6 +267,13 @@ class ExportService { private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 private readonly mediaFileCacheMaxFiles = 120000 private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 + private emojiCaptionCache = new Map() + private emojiCaptionPending = new Map>() + private emojiMd5ByCdnCache = new Map() + private emojiMd5ByCdnPending = new Map>() + private emoticonDbPathCache: string | null = null + private emoticonDbPathCacheToken = '' + private readonly emojiCaptionLookupConcurrency = 8 constructor() { this.configService = new ConfigService() @@ -919,7 +926,7 @@ class ExportService { private shouldDecodeMessageContentInFastMode(localType: number): boolean { // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 - if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { + if (localType === 3 || localType === 34 || localType === 42 || localType === 43) { return false } return true @@ -993,18 +1000,290 @@ class ExportService { 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(/&/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 { + 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, '&') + ])) + + 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 { + 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 => { + 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 { + 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 => { + 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, - imageMd5: unknown, - imageDatName: unknown, - imageDeepSearchOnMiss: boolean - ): string | null { - const normalizedSessionId = String(sessionId || '').trim() - const normalizedMd5 = String(imageMd5 || '').trim().toLowerCase() - const normalizedDatName = String(imageDatName || '').trim().toLowerCase() - if (!normalizedMd5 && !normalizedDatName) return null - const mode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' - return `${normalizedSessionId}\u001f${normalizedMd5}\u001f${normalizedDatName}\u001f${mode}` + messages: any[], + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + // 某些环境下游标行缺失 47 的 md5,先按 localId 回填详情再做 caption 查询。 + await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control) + + const unresolvedByUrl = new Map() + + const uniqueMd5s = new Set() + 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 }> { @@ -1592,8 +1871,12 @@ class ExportService { createTime?: number, myWxid?: string, senderWxid?: string, - isSend?: boolean + isSend?: boolean, + emojiCaption?: string ): string | null { + if (!content && localType === 47) { + return this.formatEmojiSemanticText(emojiCaption) + } if (!content) return null const normalizedContent = this.normalizeAppMessageContent(content) @@ -1619,7 +1902,7 @@ class ExportService { } case 42: return '[名片]' case 43: return '[视频]' - case 47: return '[动画表情]' + case 47: return this.formatEmojiSemanticText(emojiCaption) case 48: { const normalized48 = this.normalizeAppMessageContent(content) const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') @@ -1729,7 +2012,8 @@ class ExportService { voiceTranscript?: string, myWxid?: string, senderWxid?: string, - isSend?: boolean + isSend?: boolean, + emojiCaption?: string ): string { const safeContent = content || '' @@ -1759,6 +2043,9 @@ class ExportService { const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null return seconds ? `[视频]${seconds}s` : '[视频]' } + if (localType === 47) { + return this.formatEmojiSemanticText(emojiCaption) + } if (localType === 48) { const normalized = this.normalizeAppMessageContent(safeContent) 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 34: return '[语音消息]' case 43: return '[视频]' - case 47: return '[动画表情]' + case 47: return '[表情包]' case 49: case 8: return title ? `[文件] ${title}` : '[文件]' case 17: return item.chatRecordDesc || title || '[聊天记录]' @@ -2640,7 +2927,7 @@ class ExportService { displayContent = '[视频]' break case '47': - displayContent = '[动画表情]' + displayContent = '[表情包]' break case '49': displayContent = '[链接]' @@ -2953,7 +3240,17 @@ class ExportService { 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 (localType === 1) { @@ -2961,10 +3258,10 @@ class ExportService { } 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 { @@ -3535,8 +3832,11 @@ class ExportService { */ private extractEmojiMd5(content: string): string | undefined { if (!content) return undefined - const match = /md5="([^"]+)"/i.exec(content) || /([^<]+)<\/md5>/i.exec(content) - return match?.[1] + const match = + /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) || + /md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) || + /([a-fA-F0-9]{32})<\/md5>/i.exec(content) + return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content) } private extractVideoMd5(content: string): string | undefined { @@ -3825,6 +4125,7 @@ class ExportService { let locationPoiname: string | undefined let locationLabel: string | undefined let chatRecordList: any[] | undefined + let emojiCaption: string | undefined if (localType === 48 && content) { 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') { // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 imageMd5 = String(row.image_md5 || row.imageMd5 || '').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 if (localType === 3 && content) { // 图片消息 imageMd5 = imageMd5 || this.extractImageMd5(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) { // 视频消息 videoMd5 = videoMd5 || this.extractVideoMd5(content) @@ -3878,6 +4187,7 @@ class ExportService { imageDatName, emojiCdnUrl, emojiMd5, + emojiCaption, videoMd5, locationLat, locationLng, @@ -3946,7 +4256,7 @@ class ExportService { const needsBackfill = rows.filter((msg) => { if (!targetMediaTypes.has(msg.localType)) return false 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 return false }) @@ -3963,9 +4273,16 @@ class ExportService { if (!detail.success || !detail.message) return const row = detail.message as any - const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? '' - const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? '' + const rawMessageContent = this.getRowField(row, [ + '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 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) { const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) @@ -3976,8 +4293,15 @@ class ExportService { } if (msg.localType === 47) { - const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content) - const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content) + const emojiMd5 = + 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 (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl return @@ -4457,6 +4781,8 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control) + const voiceMessages = options.exportVoiceAsText ? allMessages.filter(msg => msg.localType === 34) : [] @@ -4683,7 +5009,8 @@ class ExportService { msg.createTime, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) } @@ -4775,7 +5102,7 @@ class ExportService { break case 47: recordType = 5 // EMOJI - recordContent = '[动画表情]' + recordContent = '[表情包]' break default: recordType = 0 @@ -4985,6 +5312,8 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -5164,7 +5493,7 @@ class ExportService { if (msg.localType === 34 && options.exportVoiceAsText) { content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem) { + } else if (mediaItem && msg.localType !== 47) { content = mediaItem.relativePath } else { content = this.parseMessageContent( @@ -5174,7 +5503,8 @@ class ExportService { undefined, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) } @@ -5235,6 +5565,12 @@ class ExportService { 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) if (platformMessageId) msgObj.platformMessageId = platformMessageId @@ -5470,6 +5806,9 @@ class ExportService { if (message.linkTitle) compactMessage.linkTitle = message.linkTitle if (message.linkUrl) compactMessage.linkUrl = message.linkUrl 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.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderUsername) compactMessage.finderUsername = message.finderUsername @@ -5700,6 +6039,8 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -6058,9 +6399,10 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) - : (mediaItem?.relativePath + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) || this.formatPlainExportContent( msg.content, msg.localType, @@ -6068,7 +6410,8 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption )) // 转账消息:追加 "谁转账给谁" 信息 @@ -6320,9 +6663,10 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) - : (mediaItem?.relativePath + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) || this.formatPlainExportContent( msg.content, msg.localType, @@ -6330,7 +6674,8 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption )) let enrichedContentValue = contentValue @@ -6519,6 +6864,8 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -6687,9 +7034,10 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) - : (mediaItem?.relativePath + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) || this.formatPlainExportContent( msg.content, msg.localType, @@ -6697,7 +7045,8 @@ class ExportService { voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption )) // 转账消息:追加 "谁转账给谁" 信息 @@ -6880,6 +7229,8 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const senderUsernames = new Set() let senderScanIndex = 0 for (const msg of collected.rows) { @@ -7099,7 +7450,8 @@ class ExportService { msg.createTime, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) || '') const src = this.getWeCloneSource(msg, typeName, mediaItem) const platformMessageId = this.getExportPlatformMessageId(msg) || '' @@ -7308,6 +7660,8 @@ class ExportService { } const totalMessages = collected.rows.length + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const senderUsernames = new Set() let senderScanIndex = 0 for (const msg of collected.rows) { @@ -7599,12 +7953,13 @@ class ExportService { msg.localType, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) if (msg.localType === 34 && useVoiceTranscript) { textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } - if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { + if (mediaItem && msg.localType === 3) { textContent = '' } if (this.isTransferExportContent(textContent) && msg.content) { diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 1d9e10b..7364f83 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import { join } from 'path' 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 { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -45,33 +45,104 @@ export class KeyServiceLinux { onStatus?: (message: string, level: number) => void ): Promise { try { + // 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令 + const envWithPath = { + ...process.env, + PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin` + }; + 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)) onStatus?.('正在尝试拉起微信...', 0) - const startCmds = [ - 'nohup wechat >/dev/null 2>&1 &', - 'nohup wechat-bin >/dev/null 2>&1 &', - 'nohup xwechat >/dev/null 2>&1 &' + + const cleanEnv = { ...process.env }; + delete cleanEnv.ELECTRON_RUN_AS_NODE; + 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) let pid = 0 for (let i = 0; i < 15; i++) { // 最多等 15 秒 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) - if (pids.length > 0) { - pid = parseInt(pids[0], 10) - break + + try { + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath }); + const pids = stdout.trim().split(/\s+/).filter(p => p); + 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) { - const err = '未能自动启动微信,请手动启动并登录。' + const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。' onStatus?.(err, 2) return { success: false, error: err } } @@ -82,6 +153,7 @@ export class KeyServiceLinux { return await this.getDbKey(pid, onStatus) } catch (err: any) { + console.error('[Debug] 自动获取流程彻底崩溃:', err); const errMsg = '自动获取微信 PID 失败: ' + err.message onStatus?.(errMsg, 2) return { success: false, error: errMsg } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 15497a9..e0d31f9 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -27,6 +27,17 @@ export interface SnsMedia { livePhoto?: SnsLivePhoto } +export interface SnsLocation { + latitude?: number + longitude?: number + city?: string + country?: string + poiName?: string + poiAddress?: string + poiAddressName?: string + label?: string +} + export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 @@ -39,6 +50,7 @@ export interface SnsPost { media: SnsMedia[] 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 }[] }[] + location?: SnsLocation rawXml?: string linkTitle?: string linkUrl?: string @@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] { return comments } +const decodeXmlText = (text: string): string => { + if (!text) return '' + return text + .replace(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") +} + class SnsService { private configService: ConfigService private contactCache: ContactCacheService @@ -647,6 +670,110 @@ class SnsService { 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 + 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(/]*)>/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 = (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 { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') @@ -948,7 +1075,12 @@ class SnsService { const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) 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) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), @@ -971,7 +1103,6 @@ class SnsService { // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 const dllComments: any[] = post.comments || [] const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) - const rawXml = post.rawXml || '' let finalComments: any[] if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { @@ -990,7 +1121,8 @@ class SnsService { avatarUrl: contact?.avatarUrl, nickname: post.nickname || contact?.displayName || post.username, media: fixedMedia, - comments: finalComments + comments: finalComments, + location } }) @@ -1346,6 +1478,7 @@ class SnsService { })), likes: p.likes, comments: p.comments, + location: p.location, linkTitle: (p as any).linkTitle, linkUrl: (p as any).linkUrl })) @@ -1397,6 +1530,7 @@ class SnsService { })), likes: post.likes, comments: post.comments, + location: post.location, likesDetail, commentsDetail, linkTitle: (post as any).linkTitle, @@ -1479,6 +1613,27 @@ class SnsService { const ch = name.charAt(0) 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 = '' if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` @@ -1502,6 +1657,10 @@ class SnsService { const linkHtml = post.linkTitle && post.linkUrl ? `${escapeHtml(post.linkTitle)}` : '' + const locationText = resolveLocationText(post.location) + const locationHtml = locationText + ? `
📍${escapeHtml(locationText)}
` + : '' const likesHtml = post.likes.length > 0 ? `
` @@ -1524,6 +1683,7 @@ ${avatarHtml}
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} +${locationHtml} ${mediaHtml ? `
${mediaHtml}
` : ''} ${linkHtml} ${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} .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} +.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} diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index cc75828..107cbc5 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -273,8 +273,20 @@ export class VoiceTranscribeService { }) worker.on('error', (err: Error) => resolve({ success: false, error: String(err) })) - worker.on('exit', (code: number) => { - if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` }) + worker.on('exit', (code: number | null, signal: string | null) => { + 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) { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 028f2b5..49fed8b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -68,6 +68,8 @@ export class WcdbCore { private wcdbListMediaDbs: any = null private wcdbGetMessageById: any = null private wcdbGetEmoticonCdnUrl: any = null + private wcdbGetEmoticonCaption: any = null + private wcdbGetEmoticonCaptionStrict: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null private wcdbGetVoiceDataBatch: any = null @@ -264,8 +266,9 @@ export class WcdbCore { private getDllPath(): string { const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' + const isArm64 = process.arch === 'arm64' 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 if (envDllPath && envDllPath.length > 0) { @@ -296,6 +299,26 @@ export class WcdbCore { 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 { // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志 if (process.env.WCDB_LOG_ENABLED === '1') return true @@ -621,7 +644,7 @@ export class WcdbCore { // InitProtection (Added for security) try { - this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') + this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') // 尝试多个可能的资源路径 const resourcePaths = [ @@ -634,26 +657,39 @@ export class WcdbCore { ].filter(Boolean) 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) { try { - // - protectionOk = this.wcdbInitProtection(resPath) - if (protectionOk) { - // + protectionCode = Number(this.wcdbInitProtection(resPath)) + if (protectionCode === 0) { + protectionOk = true break } + if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) { + bestFailCode = protectionCode + } + this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true) } catch (e) { - // console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) + this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true) } } if (!protectionOk) { - // console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') - // this.writeLog('InitProtection 失败,继续运行') - // 不返回 false,允许继续运行 + const finalCode = bestFailCode ?? protectionCode + lastDllInitError = this.formatInitProtectionError(finalCode) + this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true) + return false } } 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) 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) 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 }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } try { diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index d8f331d..63ff048 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -455,6 +455,20 @@ export class WcdbService { 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 }) + } + /** * 列出消息数据库 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 61b637c..43fc9e3 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -170,6 +170,12 @@ if (parentPort) { case 'getEmoticonCdnUrl': result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) break + case 'getEmoticonCaption': + result = await core.getEmoticonCaption(payload.dbPath, payload.md5) + break + case 'getEmoticonCaptionStrict': + result = await core.getEmoticonCaptionStrict(payload.md5) + break case 'listMessageDbs': result = await core.listMessageDbs() break diff --git a/package.json b/package.json index b6ec942..22177f8 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ }, "build": { "appId": "com.WeFlow.app", + "afterPack": "scripts/afterPack-sign-manifest.cjs", + "afterSign": "scripts/afterPack-sign-manifest.cjs", "publish": { "provider": "github", "owner": "hicccc77", @@ -95,6 +97,7 @@ "linux": { "icon": "public/icon.png", "target": [ + "appimage", "deb", "tar.gz" ], diff --git a/resources/arm64/WCDB.dll b/resources/arm64/WCDB.dll new file mode 100644 index 0000000..60c3508 Binary files /dev/null and b/resources/arm64/WCDB.dll differ diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll new file mode 100644 index 0000000..3f80b52 Binary files /dev/null and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 1e9abad..750a656 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/scripts/afterPack-sign-manifest.cjs b/scripts/afterPack-sign-manifest.cjs new file mode 100644 index 0000000..5b0b6ea --- /dev/null +++ b/scripts/afterPack-sign-manifest.cjs @@ -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}`); +}; diff --git a/src/App.tsx b/src/App.tsx index eb8cd5c..e09f7ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,6 +104,44 @@ function App() { // 数据收集同意状态 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(() => { if (location.pathname !== '/settings') { settingsBackgroundRef.current = location @@ -432,6 +470,8 @@ function App() { checkLock() }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) + + // 独立协议窗口 if (isAgreementWindow) { return @@ -614,6 +654,33 @@ function App() {
)} + {showWaylandWarning && ( +
+
+
+ +

环境兼容性提示 (Wayland)

+
+
+
+

检测到您当前正在使用 Wayland 显示服务器。

+

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

+

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

+
+

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

+

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

+

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

+
+
+
+
+ +
+
+
+
+ )} + {/* 更新提示对话框 */} { } } +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 [thumbFailed, setThumbFailed] = useState(false) const hostname = useMemo(() => { @@ -254,6 +278,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb const [deleting, setDeleting] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) 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 showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia const showMediaGrid = post.media.length > 0 && !showLinkCard @@ -379,6 +404,13 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb
{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}
)} + {locationText && ( +
+ + {locationText} +
+ )} + {showLinkCard && linkCard && ( )} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index d6faca4..07ef637 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -7611,6 +7611,12 @@ function MessageBubble({ const [voiceWaveform, setVoiceWaveform] = useState([]) const voiceAutoDecryptTriggered = useRef(false) + + const [systemAlert, setSystemAlert] = useState<{ + title: string; + message: React.ReactNode; + } | null>(null) + // 转账消息双方名称 const [transferPayerName, setTransferPayerName] = useState(undefined) const [transferReceiverName, setTransferReceiverName] = useState(undefined) @@ -8290,9 +8296,9 @@ function MessageBubble({ } const result = await window.electronAPI.chat.getVoiceTranscript( - session.username, - String(message.localId), - message.createTime + session.username, + String(message.localId), + message.createTime ) if (result.success) { @@ -8300,6 +8306,21 @@ function MessageBubble({ voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) setVoiceTranscript(transcriptText) } else { + if (result.error === 'SEGFAULT_ERROR') { + console.warn('[ChatPage] 捕获到语音引擎底层段错误'); + + setSystemAlert({ + title: '引擎崩溃提示', + message: ( + <> + 语音识别引擎发生底层崩溃 (Segmentation Fault)。

+ 如果您使用的是 Linux 等自定义程度较高的系统,请检查 sherpa-onnx 的相关系统动态链接库 (如 glibc 等) 是否兼容。 + + ) + }); + + } + setVoiceTranscriptError(true) voiceTranscriptRequestedRef.current = false } @@ -9699,6 +9720,31 @@ function MessageBubble({ {isSelected && } )} + {systemAlert && createPortal( +
setSystemAlert(null)} style={{ zIndex: 99999 }}> +
e.stopPropagation()} style={{ maxWidth: '400px' }}> +
+ +
+
+

{systemAlert.title}

+

+ {systemAlert.message} +

+
+
+ +
+
+
, + document.body + )} ) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 390f380..549b4ea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1027,7 +1027,7 @@ const toSessionRowsWithContacts = ( kind: toKindByContact(contact), wechatId: contact.username, displayName: contact.displayName || session?.displayName || contact.username, - avatarUrl: contact.avatarUrl || session?.avatarUrl, + avatarUrl: session?.avatarUrl || contact.avatarUrl, hasSession: Boolean(session) } as SessionRow }) @@ -1047,7 +1047,7 @@ const toSessionRowsWithContacts = ( kind: toKindByContactType(session, contact), wechatId: contact?.username || session.username, displayName: contact?.displayName || session.displayName || session.username, - avatarUrl: contact?.avatarUrl || session.avatarUrl, + avatarUrl: session.avatarUrl || contact?.avatarUrl, hasSession: true } as SessionRow }) @@ -5593,6 +5593,45 @@ function ExportPage() { return map }, [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 sessionId = String(sessionDetail?.wxid || '').trim() if (!sessionId) return [] as configService.ExportSessionRecordEntry[] @@ -5998,7 +6037,11 @@ function ExportPage() { loadSnsUserPostCounts({ force: true }) ]) - if (String(sessionDetail?.wxid || '').trim()) { + const currentDetailSessionId = showSessionDetailPanel + ? String(sessionDetail?.wxid || '').trim() + : '' + if (currentDetailSessionId) { + await loadSessionDetail(currentDetailSessionId) void loadSessionRelationStats({ forceRefresh: true }) } }, [ @@ -6009,11 +6052,13 @@ function ExportPage() { filteredContacts, isSessionCountStageReady, loadContactsList, + loadSessionDetail, loadSessionRelationStats, loadSnsStats, loadSnsUserPostCounts, resetSessionMutualFriendsLoader, scheduleSessionMutualFriendsWorker, + showSessionDetailPanel, sessionDetail?.wxid ]) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b010d93..bfed9b1 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -175,6 +175,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 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 可用性 useEffect(() => { if (window.PublicKeyCredential) { @@ -1169,6 +1184,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
选择通知弹窗在屏幕上的显示位置 + {isWayland && ( + + ⚠️ 注意:Wayland 环境下该配置可能无效! + + )}
( -
-

管理应用缓存数据

-
- - 留空使用默认目录 - { - const value = e.target.value - setCachePath(value) - scheduleConfigSave('cachePath', () => configService.setCachePath(value)) - }} - /> -
- - +
+

管理应用缓存数据

+
+ + 留空使用默认目录 + { + const value = e.target.value + setCachePath(value) + scheduleConfigSave('cachePath', () => configService.setCachePath(value)) + }} + /> + +
+ 当前缓存位置: + + {cachePath || (isMac ? '~/Documents/WeFlow' : isLinux ? '~/Documents/WeFlow' : '系统 文档\\WeFlow 目录')} + +
+ +
+ + +
-