mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -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 <<EOF
|
||||
## 更新日志
|
||||
@@ -172,10 +221,12 @@ jobs:
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
|
||||
## 下载
|
||||
- Windows (Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${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 (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||
EOF
|
||||
|
||||
@@ -104,14 +104,8 @@ npm install
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
# 4. 打包可执行文件
|
||||
npm run build
|
||||
```
|
||||
|
||||
打包产物在 `release` 目录下。
|
||||
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
|
||||
@@ -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(
|
||||
/<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 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
|
||||
@@ -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<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() {
|
||||
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<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, '&')
|
||||
]))
|
||||
|
||||
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,
|
||||
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<void> {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return
|
||||
|
||||
// 某些环境下游标行缺失 47 的 md5,先按 localId 回填详情再做 caption 查询。
|
||||
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
|
||||
|
||||
const unresolvedByUrl = new Map<string, any[]>()
|
||||
|
||||
const uniqueMd5s = new Set<string>()
|
||||
let scanIndex = 0
|
||||
for (const msg of messages) {
|
||||
if ((scanIndex++ & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
if (Number(msg?.localType) !== 47) continue
|
||||
|
||||
const content = String(msg?.content || '')
|
||||
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|
||||
|| this.extractEmojiMd5(content)
|
||||
|| this.extractLooseHexMd5(content)
|
||||
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
|
||||
if (normalizedCdnUrl) {
|
||||
msg.emojiCdnUrl = normalizedCdnUrl
|
||||
}
|
||||
if (!normalizedMd5) {
|
||||
if (normalizedCdnUrl) {
|
||||
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
|
||||
bucket.push(msg)
|
||||
unresolvedByUrl.set(normalizedCdnUrl, bucket)
|
||||
} else {
|
||||
msg.emojiMd5 = undefined
|
||||
msg.emojiCaption = undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
msg.emojiMd5 = normalizedMd5
|
||||
uniqueMd5s.add(normalizedMd5)
|
||||
}
|
||||
|
||||
const unresolvedUrls = Array.from(unresolvedByUrl.keys())
|
||||
if (unresolvedUrls.length > 0) {
|
||||
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
|
||||
if ((index & 0x0f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
|
||||
if (!resolvedMd5) return
|
||||
const attached = unresolvedByUrl.get(url) || []
|
||||
for (const msg of attached) {
|
||||
msg.emojiMd5 = resolvedMd5
|
||||
uniqueMd5s.add(resolvedMd5)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const md5List = Array.from(uniqueMd5s)
|
||||
if (md5List.length > 0) {
|
||||
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
|
||||
if ((index & 0x0f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
await this.getEmojiCaptionByMd5(md5)
|
||||
})
|
||||
}
|
||||
|
||||
let assignIndex = 0
|
||||
for (const msg of messages) {
|
||||
if ((assignIndex++ & 0x7f) === 0) {
|
||||
this.throwIfStopRequested(control)
|
||||
}
|
||||
if (Number(msg?.localType) !== 47) continue
|
||||
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|
||||
if (!md5) {
|
||||
msg.emojiCaption = undefined
|
||||
continue
|
||||
}
|
||||
const caption = this.emojiCaptionCache.get(md5) ?? null
|
||||
msg.emojiCaption = caption || undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||
@@ -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>([^<]+)<\/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) ||
|
||||
/<md5>([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<string>()
|
||||
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<string>()
|
||||
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) {
|
||||
|
||||
@@ -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<DbKeyResult> {
|
||||
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 }
|
||||
|
||||
@@ -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(/<!\[CDATA\[([\s\S]*?)\]\]>/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<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 {
|
||||
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
|
||||
? `<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
|
||||
? `<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="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>` : ''}
|
||||
${locationHtml}
|
||||
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||||
${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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出消息数据库
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
BIN
resources/arm64/WCDB.dll
Normal file
BIN
resources/arm64/WCDB.dll
Normal file
Binary file not shown.
BIN
resources/arm64/wcdb_api.dll
Normal file
BIN
resources/arm64/wcdb_api.dll
Normal file
Binary file not shown.
Binary file not shown.
251
scripts/afterPack-sign-manifest.cjs
Normal file
251
scripts/afterPack-sign-manifest.cjs
Normal 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}`);
|
||||
};
|
||||
67
src/App.tsx
67
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 <AgreementPage />
|
||||
@@ -614,6 +654,33 @@ function App() {
|
||||
</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
|
||||
open={showUpdateDialog}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
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 [thumbFailed, setThumbFailed] = useState(false)
|
||||
const hostname = useMemo(() => {
|
||||
@@ -254,6 +278,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ 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<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
<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 && (
|
||||
<SnsLinkCard card={linkCard} />
|
||||
)}
|
||||
|
||||
@@ -7611,6 +7611,12 @@ function MessageBubble({
|
||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||
const voiceAutoDecryptTriggered = useRef(false)
|
||||
|
||||
|
||||
const [systemAlert, setSystemAlert] = useState<{
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
} | null>(null)
|
||||
|
||||
// 转账消息双方名称
|
||||
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
|
||||
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(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)。<br /><br />
|
||||
如果您使用的是 Linux 等自定义程度较高的系统,请检查 <code>sherpa-onnx</code> 的相关系统动态链接库 (如 glibc 等) 是否兼容。
|
||||
</>
|
||||
)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
setVoiceTranscriptError(true)
|
||||
voiceTranscriptRequestedRef.current = false
|
||||
}
|
||||
@@ -9699,6 +9720,31 @@ function MessageBubble({
|
||||
{isSelected && <Check size={14} strokeWidth={3} />}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
|
||||
@@ -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 = {}) {
|
||||
<div className="form-group">
|
||||
<label>通知显示位置</label>
|
||||
<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-trigger ${positionDropdownOpen ? 'open' : ''}`}
|
||||
@@ -1652,34 +1672,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
<div className="form-group">
|
||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">留空使用默认目录</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="留空使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setCachePath(value)
|
||||
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<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 className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
<div className="form-group">
|
||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">留空使用默认目录</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="留空使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setCachePath(value)
|
||||
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
当前缓存位置:
|
||||
<code style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
userSelect: 'all',
|
||||
wordBreak: 'break-all',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
{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 className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
||||
|
||||
@@ -759,6 +759,26 @@
|
||||
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 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
11
src/types/electron.d.ts
vendored
11
src/types/electron.d.ts
vendored
@@ -61,6 +61,7 @@ export interface ElectronAPI {
|
||||
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
|
||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||
checkWayland: () => Promise<boolean>
|
||||
}
|
||||
notification: {
|
||||
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>
|
||||
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
|
||||
}>
|
||||
error?: string
|
||||
|
||||
@@ -34,6 +34,17 @@ export interface SnsComment {
|
||||
emojis?: SnsCommentEmoji[]
|
||||
}
|
||||
|
||||
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),用于精确删除
|
||||
@@ -46,6 +57,7 @@ export interface SnsPost {
|
||||
media: SnsMedia[]
|
||||
likes: string[]
|
||||
comments: SnsComment[]
|
||||
location?: SnsLocation
|
||||
rawXml?: string
|
||||
linkTitle?: string
|
||||
linkUrl?: string
|
||||
|
||||
@@ -4,6 +4,10 @@ import electron from 'vite-plugin-electron'
|
||||
import renderer from 'vite-plugin-electron-renderer'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const handleElectronOnStart = (options: { reload: () => void }) => {
|
||||
options.reload()
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
server: {
|
||||
@@ -23,6 +27,7 @@ export default defineConfig({
|
||||
electron([
|
||||
{
|
||||
entry: 'electron/main.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -43,6 +48,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/annualReportWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -61,6 +67,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/dualReportWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -79,6 +86,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/imageSearchWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -93,6 +101,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/wcdbWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -112,6 +121,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/transcribeWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -129,6 +139,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/exportWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
@@ -149,9 +160,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
entry: 'electron/preload.ts',
|
||||
onstart(options) {
|
||||
options.reload()
|
||||
},
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron'
|
||||
|
||||
Reference in New Issue
Block a user