diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac6d7ab..bb7495e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,6 @@ 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 @@ -84,8 +82,6 @@ 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 @@ -122,8 +118,6 @@ 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 @@ -160,10 +154,8 @@ jobs: - 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 + npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' update-release-notes: runs-on: ubuntu-latest @@ -192,7 +184,7 @@ jobs: echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' } - WINDOWS_ASSET="$(pick_asset "\\.exe$")" + WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')" WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" MAC_ASSET="$(pick_asset "\\.dmg$")" LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e8d1c1f..4a71e88 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -6,7 +6,7 @@ import * as https from 'https' import * as http from 'http' import * as fzstd from 'fzstd' import * as crypto from 'crypto' -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, dialog } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' @@ -292,6 +292,7 @@ class ChatService { private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 private groupMyMessageCountCacheScope = '' private groupMyMessageCountMemoryCache = new Map() + private initFailureDialogShown = false constructor() { this.configService = new ConfigService() @@ -338,6 +339,55 @@ class ChatService { return true } + private extractErrorCode(message?: string): number | null { + const text = String(message || '').trim() + if (!text) return null + const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/) + if (!match) return null + const parsed = Number(match[1]) + return Number.isFinite(parsed) ? parsed : null + } + + private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string { + const code = this.extractErrorCode(rawMessage) ?? fallbackCode + return `错误码: ${code}` + } + + private async maybeShowInitFailureDialog(errorMessage: string): Promise { + if (!app.isPackaged) return + if (this.initFailureDialogShown) return + + const code = this.extractErrorCode(errorMessage) + if (code === null) return + const isSecurityCode = + code === -101 || + code === -102 || + code === -2299 || + code === -2301 || + code === -2302 || + code === -1006 || + (code <= -2201 && code >= -2212) + if (!isSecurityCode) return + + this.initFailureDialogShown = true + const detail = [ + `错误码: ${code}` + ].join('\n') + + try { + await dialog.showMessageBox({ + type: 'error', + title: 'WeFlow 启动失败', + message: '启动失败,请反馈错误码。', + detail, + buttons: ['确定'], + noLink: true + }) + } catch { + // 弹窗失败不阻断主流程 + } + } + /** * 连接数据库 */ @@ -362,7 +412,9 @@ class ChatService { const cleanedWxid = this.cleanAccountDirName(wxid) const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) if (!openOk) { - return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' } + const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError()) + await this.maybeShowInitFailureDialog(detailedError) + return { success: false, error: detailedError } } this.connected = true @@ -376,7 +428,7 @@ class ChatService { return { success: true } } catch (e) { console.error('ChatService: 连接数据库失败:', e) - return { success: false, error: String(e) } + return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) } } } @@ -5003,7 +5055,17 @@ class ChatService { const contact = await this.getContact(username) const avatarResult = await wcdbService.getAvatarUrls([username]) - const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + if (!this.isValidAvatarUrl(avatarUrl)) { + avatarUrl = undefined + } + if (!avatarUrl) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb([username]) + const fallbackAvatarUrl = headImageAvatars[username] + if (this.isValidAvatarUrl(fallbackAvatarUrl)) { + avatarUrl = fallbackAvatarUrl + } + } const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username const cacheEntry: ContactCacheEntry = { avatarUrl, @@ -5471,6 +5533,13 @@ class ChatService { avatarUrl = avatarCandidate } } + if (!avatarUrl) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId]) + const fallbackAvatarUrl = headImageAvatars[normalizedSessionId] + if (this.isValidAvatarUrl(fallbackAvatarUrl)) { + avatarUrl = fallbackAvatarUrl + } + } if (!Number.isFinite(messageCount)) { messageCount = messageCountResult.status === 'fulfilled' && diff --git a/electron/services/config.ts b/electron/services/config.ts index 4229069..c293ee1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -53,6 +53,7 @@ interface ConfigSchema { notificationFilterList: string[] messagePushEnabled: boolean windowCloseBehavior: 'ask' | 'tray' | 'quit' + quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } @@ -120,6 +121,7 @@ export class ConfigService { notificationFilterList: [], messagePushEnabled: false, windowCloseBehavior: 'ask', + quoteLayout: 'quote-top', wordCloudExcludeWords: [] } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0a13243..e0f43f3 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1000,6 +1000,26 @@ class ExportService { return `${localType}_${this.getStableMessageKey(msg)}` } + private getImageMissingRunCacheKey( + sessionId: string, + imageMd5?: unknown, + imageDatName?: unknown, + imageDeepSearchOnMiss = true + ): string | null { + const normalizedSessionId = String(sessionId || '').trim() + const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase() + const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase() + if (!normalizedSessionId) return null + if (!normalizedImageMd5 && !normalizedImageDatName) return null + + const primaryToken = normalizedImageMd5 || normalizedImageDatName + const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5 + ? normalizedImageDatName + : '' + const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink' + return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}` + } + private normalizeEmojiMd5(value: unknown): string | undefined { const md5 = String(value || '').trim().toLowerCase() if (!/^[a-f0-9]{32}$/.test(md5)) return undefined diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 49fed8b..9bb2a9d 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -126,6 +126,10 @@ export class WcdbCore { this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) } + getLastInitError(): string | null { + return lastDllInitError + } + setLogEnabled(enabled: boolean): void { this.logEnabled = enabled this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) @@ -300,23 +304,7 @@ export class WcdbCore { } 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})` - } + return `错误码: ${code}` } private isLogEnabled(): boolean { @@ -640,7 +628,9 @@ export class WcdbCore { } } + this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true) this.lib = this.koffi.load(dllPath) + this.writeLog('[bootstrap] koffi.load ok', true) // InitProtection (Added for security) try { @@ -666,6 +656,7 @@ export class WcdbCore { } for (const resPath of resourcePaths) { try { + this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true) protectionCode = Number(this.wcdbInitProtection(resPath)) if (protectionCode === 0) { protectionOk = true @@ -687,7 +678,7 @@ export class WcdbCore { return false } } catch (e) { - lastDllInitError = `InitProtection symbol not found: ${String(e)}` + lastDllInitError = this.formatInitProtectionError(-2301) this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true) return false } @@ -1107,7 +1098,7 @@ export class WcdbCore { const initResult = this.wcdbInit() if (initResult !== 0) { console.error('WCDB 初始化失败:', initResult) - lastDllInitError = `初始化失败(错误码: ${initResult})` + lastDllInitError = this.formatInitProtectionError(initResult) return false } @@ -1118,14 +1109,7 @@ export class WcdbCore { const errorMsg = e instanceof Error ? e.message : String(e) console.error('WCDB 初始化异常:', errorMsg) this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) - lastDllInitError = errorMsg - // 检查是否是常见的 VC++ 运行时缺失错误 - if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || - errorMsg.includes('The specified module could not be found')) { - lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' - } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { - lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' - } + lastDllInitError = this.formatInitProtectionError(-2302) return false } } @@ -1152,8 +1136,7 @@ export class WcdbCore { if (!this.initialized) { const initOk = await this.initialize() if (!initOk) { - // 返回更详细的错误信息,帮助用户诊断问题 - const detailedError = lastDllInitError || 'WCDB 初始化失败' + const detailedError = lastDllInitError || this.formatInitProtectionError(-2303) return { success: false, error: detailedError } } } @@ -1163,7 +1146,7 @@ export class WcdbCore { this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) if (!dbStoragePath || !existsSync(dbStoragePath)) { - return { success: false, error: `数据库目录不存在: ${dbPath}` } + return { success: false, error: this.formatInitProtectionError(-3001) } } // 递归查找 session.db @@ -1171,7 +1154,7 @@ export class WcdbCore { this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) if (!sessionDbPath) { - return { success: false, error: `未找到 session.db 文件` } + return { success: false, error: this.formatInitProtectionError(-3002) } } // 分配输出参数内存 @@ -1180,17 +1163,13 @@ export class WcdbCore { if (result !== 0) { await this.printLogs() - let errorMsg = '数据库打开失败' - if (result === -1) errorMsg = '参数错误' - else if (result === -2) errorMsg = '密钥错误' - else if (result === -3) errorMsg = '数据库打开失败' this.writeLog(`testConnection openAccount failed code=${result}`) - return { success: false, error: `${errorMsg} (错误码: ${result})` } + return { success: false, error: this.formatInitProtectionError(result) } } const tempHandle = handleOut[0] if (tempHandle <= 0) { - return { success: false, error: '无效的数据库句柄' } + return { success: false, error: this.formatInitProtectionError(-3003) } } // 测试成功:使用 shutdown 清理资源(包括测试句柄) @@ -1219,7 +1198,7 @@ export class WcdbCore { } catch (e) { console.error('测试连接异常:', e) this.writeLog(`testConnection exception: ${String(e)}`) - return { success: false, error: String(e) } + return { success: false, error: this.formatInitProtectionError(-3004) } } } @@ -1411,6 +1390,7 @@ export class WcdbCore { */ async open(dbPath: string, hexKey: string, wxid: string): Promise { try { + lastDllInitError = null if (!this.initialized) { const initOk = await this.initialize() if (!initOk) return false @@ -1438,6 +1418,7 @@ export class WcdbCore { if (!dbStoragePath || !existsSync(dbStoragePath)) { console.error('数据库目录不存在:', dbPath) this.writeLog(`open failed: dbStorage not found for ${dbPath}`) + lastDllInitError = this.formatInitProtectionError(-3001) return false } @@ -1446,6 +1427,7 @@ export class WcdbCore { if (!sessionDbPath) { console.error('未找到 session.db 文件') this.writeLog('open failed: session.db not found') + lastDllInitError = this.formatInitProtectionError(-3002) return false } @@ -1456,11 +1438,13 @@ export class WcdbCore { console.error('打开数据库失败:', result) await this.printLogs() this.writeLog(`open failed: openAccount code=${result}`) + lastDllInitError = this.formatInitProtectionError(result) return false } const handle = handleOut[0] if (handle <= 0) { + lastDllInitError = this.formatInitProtectionError(-3003) return false } @@ -1470,6 +1454,7 @@ export class WcdbCore { this.currentWxid = wxid this.currentDbStoragePath = dbStoragePath this.initialized = true + lastDllInitError = null if (this.wcdbSetMyWxid && wxid) { try { this.wcdbSetMyWxid(this.handle, wxid) @@ -1487,6 +1472,7 @@ export class WcdbCore { } catch (e) { console.error('打开数据库异常:', e) this.writeLog(`open exception: ${String(e)}`) + lastDllInitError = this.formatInitProtectionError(-3004) return false } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 63ff048..f52de6c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -164,6 +164,10 @@ export class WcdbService { return this.callWorker('open', { dbPath, hexKey, wxid }) } + async getLastInitError(): Promise { + return this.callWorker('getLastInitError') + } + /** * 关闭数据库连接 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 43fc9e3..898084d 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -37,6 +37,9 @@ if (parentPort) { case 'open': result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) break + case 'getLastInitError': + result = core.getLastInitError() + break case 'close': core.close() result = { success: true } diff --git a/package.json b/package.json index 22177f8..0b237bc 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,6 @@ }, "build": { "appId": "com.WeFlow.app", - "afterPack": "scripts/afterPack-sign-manifest.cjs", - "afterSign": "scripts/afterPack-sign-manifest.cjs", "publish": { "provider": "github", "owner": "hicccc77", diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll index 3f80b52..85d1ea8 100644 Binary files a/resources/arm64/wcdb_api.dll and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so index f08bafa..e206d60 100755 Binary files a/resources/linux/libwcdb_api.so and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index 07cb87f..db376bb 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 750a656..100bbc2 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/scripts/afterPack-sign-manifest.cjs b/scripts/afterPack-sign-manifest.cjs deleted file mode 100644 index 5b0b6ea..0000000 --- a/scripts/afterPack-sign-manifest.cjs +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable no-console */ -const fs = require('node:fs'); -const path = require('node:path'); -const crypto = require('node:crypto'); - -const MANIFEST_NAME = '.wf_manifest.json'; -const SIGNATURE_NAME = '.wf_manifest.sig'; -const MODULE_FILENAME = { - win32: 'wcdb_api.dll', - darwin: 'wcdb_api.dylib', - linux: 'wcdb_api.so', -}; - -function readTextIfExists(filePath) { - try { - if (!fs.existsSync(filePath)) return null; - return fs.readFileSync(filePath, 'utf8'); - } catch { - return null; - } -} - -function loadEnvFile(projectDir, fileName) { - const envPath = path.join(projectDir, fileName); - const content = readTextIfExists(envPath); - if (!content) return false; - - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq <= 0) continue; - const key = trimmed.slice(0, eq).trim(); - let value = trimmed.slice(eq + 1).trim(); - if (!key || process.env[key] !== undefined) continue; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[key] = value; - } - return true; -} - -function ensureSigningEnv() { - const projectDir = process.cwd(); - if (!process.env.WF_SIGN_PRIVATE_KEY) { - loadEnvFile(projectDir, '.env.local'); - loadEnvFile(projectDir, '.env'); - } - - const keyB64 = (process.env.WF_SIGN_PRIVATE_KEY || '').trim(); - const required = (process.env.WF_SIGNING_REQUIRED || '').trim() === '1'; - if (!keyB64) { - if (required) { - throw new Error( - 'WF_SIGN_PRIVATE_KEY is missing (WF_SIGNING_REQUIRED=1). ' + - 'Set it in CI Secret or .env.local for local build.', - ); - } - return null; - } - return keyB64; -} - -function getPlatform(context) { - return ( - context?.electronPlatformName || - context?.packager?.platform?.name || - process.platform - ); -} - -function getProductFilename(context) { - return ( - context?.packager?.appInfo?.productFilename || - context?.packager?.config?.productName || - 'WeFlow' - ); -} - -function getResourcesDir(appOutDir) { - if (appOutDir.endsWith('.app')) { - return path.join(appOutDir, 'Contents', 'Resources'); - } - return path.join(appOutDir, 'resources'); -} - -function normalizeRel(baseDir, filePath) { - return path.relative(baseDir, filePath).split(path.sep).join('/'); -} - -function sha256FileHex(filePath) { - const data = fs.readFileSync(filePath); - return crypto.createHash('sha256').update(data).digest('hex'); -} - -function findFirstExisting(paths) { - for (const p of paths) { - if (p && fs.existsSync(p) && fs.statSync(p).isFile()) return p; - } - return null; -} - -function findExecutablePath({ appOutDir, platform, productFilename, executableName }) { - if (platform === 'win32') { - return findFirstExisting([ - path.join(appOutDir, `${productFilename}.exe`), - path.join(appOutDir, `${executableName || ''}.exe`), - ]); - } - - if (platform === 'darwin') { - const macOsDir = path.join(appOutDir, 'Contents', 'MacOS'); - const preferred = findFirstExisting([path.join(macOsDir, productFilename)]); - if (preferred) return preferred; - if (!fs.existsSync(macOsDir)) return null; - const files = fs - .readdirSync(macOsDir) - .map((name) => path.join(macOsDir, name)) - .filter((p) => fs.statSync(p).isFile()); - return files[0] || null; - } - - return findFirstExisting([ - path.join(appOutDir, executableName || ''), - path.join(appOutDir, productFilename), - path.join(appOutDir, productFilename.toLowerCase()), - ]); -} - -function findByBasenameRecursive(rootDir, basename) { - if (!fs.existsSync(rootDir)) return null; - const stack = [rootDir]; - while (stack.length > 0) { - const dir = stack.pop(); - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - stack.push(full); - } else if (entry.isFile() && entry.name.toLowerCase() === basename.toLowerCase()) { - return full; - } - } - } - return null; -} - -function getModulePath(resourcesDir, appOutDir, platform) { - const filename = MODULE_FILENAME[platform] || MODULE_FILENAME[process.platform]; - if (!filename) return null; - - const direct = findFirstExisting([ - path.join(resourcesDir, 'resources', filename), - path.join(resourcesDir, filename), - ]); - if (direct) return direct; - - const inResources = findByBasenameRecursive(resourcesDir, filename); - if (inResources) return inResources; - - return findByBasenameRecursive(appOutDir, filename); -} - -function signDetachedEd25519(payloadUtf8, privateKeyDerB64) { - const privateKeyDer = Buffer.from(privateKeyDerB64, 'base64'); - const keyObject = crypto.createPrivateKey({ - key: privateKeyDer, - format: 'der', - type: 'pkcs8', - }); - return crypto.sign(null, Buffer.from(payloadUtf8, 'utf8'), keyObject); -} - -module.exports = async function afterPack(context) { - const privateKeyDerB64 = ensureSigningEnv(); - if (!privateKeyDerB64) { - console.log('[wf-sign] skip: WF_SIGN_PRIVATE_KEY not provided and signing not required.'); - return; - } - - const appOutDir = context?.appOutDir; - if (!appOutDir || !fs.existsSync(appOutDir)) { - throw new Error(`[wf-sign] invalid appOutDir: ${String(appOutDir)}`); - } - - const platform = String(getPlatform(context)).toLowerCase(); - const productFilename = getProductFilename(context); - const executableName = context?.packager?.config?.linux?.executableName || ''; - const resourcesDir = getResourcesDir(appOutDir); - if (!fs.existsSync(resourcesDir)) { - throw new Error(`[wf-sign] resources directory not found: ${resourcesDir}`); - } - - const exePath = findExecutablePath({ - appOutDir, - platform, - productFilename, - executableName, - }); - if (!exePath) { - throw new Error( - `[wf-sign] executable not found. platform=${platform}, appOutDir=${appOutDir}, productFilename=${productFilename}`, - ); - } - - const modulePath = getModulePath(resourcesDir, appOutDir, platform); - if (!modulePath) { - throw new Error( - `[wf-sign] ${MODULE_FILENAME[platform] || 'wcdb_api'} not found under resources: ${resourcesDir}`, - ); - } - - const manifest = { - schema: 1, - platform, - version: context?.packager?.appInfo?.version || '', - generatedAt: new Date().toISOString(), - targets: [ - { - id: 'exe', - path: normalizeRel(resourcesDir, exePath), - sha256: sha256FileHex(exePath), - }, - { - id: 'module', - path: normalizeRel(resourcesDir, modulePath), - sha256: sha256FileHex(modulePath), - }, - ], - }; - - const payload = `${JSON.stringify(manifest, null, 2)}\n`; - const signature = signDetachedEd25519(payload, privateKeyDerB64).toString('base64'); - - const manifestPath = path.join(resourcesDir, MANIFEST_NAME); - const signaturePath = path.join(resourcesDir, SIGNATURE_NAME); - fs.writeFileSync(manifestPath, payload, 'utf8'); - fs.writeFileSync(signaturePath, `${signature}\n`, 'utf8'); - - console.log(`[wf-sign] manifest: ${manifestPath}`); - console.log(`[wf-sign] signature: ${signaturePath}`); - console.log(`[wf-sign] exe: ${manifest.targets[0].path}`); - console.log(`[wf-sign] exe.sha256: ${manifest.targets[0].sha256}`); - console.log(`[wf-sign] module: ${manifest.targets[1].path}`); - console.log(`[wf-sign] module.sha256: ${manifest.targets[1].sha256}`); -}; diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8776e5f..89049bf 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2420,7 +2420,6 @@ background: rgba(0, 0, 0, 0.04); border-left: 2px solid var(--primary); padding: 6px 10px; - margin-bottom: 8px; border-radius: 4px; font-size: 13px; @@ -2482,6 +2481,14 @@ .bubble-content { -webkit-app-region: no-drag; + + &.quote-layout-top .quoted-message { + margin-bottom: 8px; + } + + &.quote-layout-bottom .quoted-message { + margin-top: 8px; + } } // 时间分隔 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 07ef637..e9f8eda 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -52,6 +52,8 @@ interface GlobalMsgPrefixCacheEntry { completed: boolean } +type QuoteLayout = configService.QuoteLayout + const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 @@ -7556,6 +7558,7 @@ function MessageBubble({ const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) + const [quoteLayout, setQuoteLayout] = useState('quote-top') const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -8549,6 +8552,18 @@ function MessageBubble({ myWxid ]) + useEffect(() => { + let cancelled = false + void configService.getQuoteLayout().then((layout) => { + if (!cancelled) setQuoteLayout(layout) + }).catch(() => { + if (!cancelled) setQuoteLayout('quote-top') + }) + return () => { + cancelled = true + } + }, []) + const locationMessageMeta = useMemo(() => { if (message.localType !== 48) return null const raw = message.rawContent || '' @@ -8584,6 +8599,31 @@ function MessageBubble({ // 是否有引用消息 const hasQuote = quotedContent.length > 0 const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName + const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => { + const quoteFirst = quoteLayout !== 'quote-bottom' + return ( +
+ {quoteFirst ? ( + <> + {quotedNode} + {messageNode} + + ) : ( + <> + {messageNode} + {quotedNode} + + )} +
+ ) + }, [quoteLayout]) + + const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( +
+ {displayQuotedSenderName && {displayQuotedSenderName}} + {contentNode} +
+ ), [displayQuotedSenderName]) const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -9023,13 +9063,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9122,13 +9159,10 @@ function MessageBubble({ const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(referContent))} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9338,13 +9372,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent2()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent2()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9623,14 +9654,9 @@ function MessageBubble({ // 带引用的消息 if (hasQuote) { - return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(quotedContent))} -
-
{renderTextWithEmoji(cleanedParsedContent)}
-
+ return renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))), +
{renderTextWithEmoji(cleanedParsedContent)}
) } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 549b4ea..1f4a6cc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -49,6 +49,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' +import { Avatar } from '../components/Avatar' import type { SnsPost } from '../types/sns' import { cloneExportDateRange, @@ -538,6 +539,14 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const normalizeExportAvatarUrl = (value?: string | null): string | undefined => { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (lower === 'null' || lower === 'undefined') return undefined + return normalized +} + const toComparableNameSet = (values: Array): Set => { const set = new Set() for (const value of values) { @@ -1713,6 +1722,7 @@ function ExportPage() { startIndex: 0, endIndex: -1 }) + const avatarHydrationRequestedRef = useRef>(new Set()) const sessionMutualFriendsMetricsRef = useRef>({}) const sessionMutualFriendsDirectMetricsRef = useRef>({}) const sessionMutualFriendsQueueRef = useRef([]) @@ -1957,6 +1967,7 @@ function ExportPage() { displayName: contact.displayName, remark: contact.remark, nickname: contact.nickname, + alias: contact.alias, type: contact.type })) ).catch((error) => { @@ -1998,6 +2009,94 @@ function ExportPage() { } }, [ensureExportCacheScope, syncContactTypeCounts]) + const hydrateVisibleContactAvatars = useCallback(async (usernames: string[]) => { + const targets = Array.from(new Set( + (usernames || []) + .map((username) => String(username || '').trim()) + .filter(Boolean) + )).filter((username) => { + if (avatarHydrationRequestedRef.current.has(username)) return false + const contact = contactsList.find((item) => item.username === username) + const session = sessions.find((item) => item.username === username) + const existingAvatarUrl = normalizeExportAvatarUrl(contact?.avatarUrl || session?.avatarUrl) + return !existingAvatarUrl + }) + + if (targets.length === 0) return + targets.forEach((username) => avatarHydrationRequestedRef.current.add(username)) + + const settled = await Promise.allSettled( + targets.map(async (username) => { + const profile = await window.electronAPI.chat.getContactAvatar(username) + return { + username, + avatarUrl: normalizeExportAvatarUrl(profile?.avatarUrl), + displayName: profile?.displayName ? String(profile.displayName).trim() : undefined + } + }) + ) + + const avatarPatches = new Map() + for (const item of settled) { + if (item.status !== 'fulfilled') continue + const { username, avatarUrl, displayName } = item.value + if (!avatarUrl && !displayName) continue + avatarPatches.set(username, { avatarUrl, displayName }) + } + if (avatarPatches.size === 0) return + + const now = Date.now() + setContactsList((prev) => prev.map((contact) => { + const patch = avatarPatches.get(contact.username) + if (!patch) return contact + return { + ...contact, + displayName: patch.displayName || contact.displayName, + avatarUrl: patch.avatarUrl || contact.avatarUrl + } + })) + setSessions((prev) => prev.map((session) => { + const patch = avatarPatches.get(session.username) + if (!patch) return session + return { + ...session, + displayName: patch.displayName || session.displayName, + avatarUrl: patch.avatarUrl || session.avatarUrl + } + })) + setSessionDetail((prev) => { + if (!prev) return prev + const patch = avatarPatches.get(prev.wxid) + if (!patch) return prev + return { + ...prev, + displayName: patch.displayName || prev.displayName, + avatarUrl: patch.avatarUrl || prev.avatarUrl + } + }) + + let avatarCacheChanged = false + for (const [username, patch] of avatarPatches.entries()) { + if (!patch.avatarUrl) continue + const previous = contactsAvatarCacheRef.current[username] + if (previous?.avatarUrl === patch.avatarUrl) continue + contactsAvatarCacheRef.current[username] = { + avatarUrl: patch.avatarUrl, + updatedAt: now, + checkedAt: now + } + avatarCacheChanged = true + } + if (avatarCacheChanged) { + setAvatarCacheUpdatedAt(now) + const scopeKey = exportCacheScopeRef.current + if (scopeKey) { + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch(() => {}) + } + } + }, [contactsList, sessions]) + + useEffect(() => { if (!isExportRoute) return let cancelled = false @@ -3824,10 +3923,12 @@ function ExportPage() { displayName: contact.displayName || contact.username, remark: contact.remark, nickname: contact.nickname, + alias: contact.alias, type: contact.type })) const persistAt = Date.now() + setContactsList(contactsForPersist) setSessions(nextSessions) sessionsHydratedAtRef.current = persistAt if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { @@ -5380,6 +5481,11 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } + void hydrateVisibleContactAvatars( + filteredContacts + .slice(startIndex, endIndex + 1) + .map((contact) => contact.username) + ) const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) @@ -5395,10 +5501,23 @@ function ExportPage() { enqueueSessionMediaMetricRequests, enqueueSessionMutualFriendsRequests, filteredContacts, + hydrateVisibleContactAvatars, scheduleSessionMediaMetricWorker, scheduleSessionMutualFriendsWorker ]) + useEffect(() => { + if (filteredContacts.length === 0) return + const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username) + void hydrateVisibleContactAvatars(bootstrapTargets) + }, [filteredContacts, hydrateVisibleContactAvatars]) + + useEffect(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return + void hydrateVisibleContactAvatars([sessionId]) + }, [hydrateVisibleContactAvatars, sessionDetail?.wxid]) + useEffect(() => { if (activeTaskCount > 0) return if (filteredContacts.length === 0) return @@ -5750,7 +5869,7 @@ function ExportPage() { displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, remark: sameSession ? prev?.remark : mappedContact?.remark, nickName: sameSession ? prev?.nickName : mappedContact?.nickname, - alias: sameSession ? prev?.alias : undefined, + alias: sameSession ? prev?.alias : mappedContact?.alias, avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined), @@ -6627,11 +6746,12 @@ function ExportPage() {
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} +
{contact.displayName}
@@ -7514,11 +7634,12 @@ function ExportPage() {
- {sessionMutualFriendsDialogTarget.avatarUrl ? ( - - ) : ( - {getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)} - )} +

{sessionMutualFriendsDialogTarget.displayName} 的共同好友

@@ -7599,11 +7720,12 @@ function ExportPage() {
- {sessionDetail?.avatarUrl ? ( - - ) : ( - {getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')} - )} +

{sessionDetail?.displayName || '会话详情'}

diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 6b8bf8c..2de9358 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1145,6 +1145,134 @@ } } +.quote-layout-picker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; +} + +.quote-layout-card { + position: relative; + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 14px; + background: var(--bg-primary); + color: inherit; + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + transform: translateY(-1px); + } + + &.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--primary) 8%); + } +} + +.quote-layout-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.quote-layout-card-title-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.quote-layout-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.quote-layout-card-desc { + font-size: 12px; + color: var(--text-tertiary); +} + +.quote-layout-card-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--border-color); + color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s ease; + + &.active { + border-color: var(--primary); + background: var(--primary); + color: #fff; + } +} + +.quote-layout-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + min-height: 112px; + + &.quote-bottom { + .quote-layout-preview-message { + order: 1; + } + + .quote-layout-preview-quote { + order: 2; + } + } +} + +.quote-layout-preview-quote { + padding: 8px 10px; + border-left: 2px solid var(--primary); + border-radius: 8px; + background: rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + gap: 3px; +} + +.quote-layout-preview-sender { + font-size: 12px; + font-weight: 600; + color: var(--primary); +} + +.quote-layout-preview-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.quote-layout-preview-message { + align-self: flex-start; + max-width: 88%; + padding: 9px 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--primary) 14%, var(--bg-primary)); + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} + .theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index bfed9b1..36fc4ed 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -118,6 +118,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') + const [quoteLayout, setQuoteLayout] = useState('quote-top') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) @@ -314,6 +315,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() + const savedQuoteLayout = await configService.getQuoteLayout() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() @@ -351,6 +353,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) setWindowCloseBehavior(savedWindowCloseBehavior) + setQuoteLayout(savedQuoteLayout) const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) @@ -1058,6 +1061,77 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ))}
+
+ + 选择聊天中引用消息与正文的上下顺序,右侧预览会同步展示布局差异。 +
+ {[ + { + value: 'quote-top' as const, + label: '引用在上', + description: '更接近当前 WeFlow 风格', + successMessage: '已切换为引用在上样式' + }, + { + value: 'quote-bottom' as const, + label: '正文在上', + description: '更接近微信 / 密语风格', + successMessage: '已切换为正文在上样式' + } + ].map(option => { + const selected = quoteLayout === option.value + const quotePreview = ( +
+ 张三 + 这是一条被引用的消息 +
+ ) + const messagePreview = ( +
这是当前发送的回复内容
+ ) + + return ( + + ) + })} +
+
+
diff --git a/src/services/config.ts b/src/services/config.ts index f5bfb53..37c404f 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -66,6 +66,7 @@ export const CONFIG_KEYS = { NOTIFICATION_FILTER_LIST: 'notificationFilterList', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', + QUOTE_LAYOUT: 'quoteLayout', // 词云 WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', @@ -90,6 +91,7 @@ export interface ExportDefaultMediaConfig { } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' +export type QuoteLayout = 'quote-top' | 'quote-bottom' const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, @@ -660,6 +662,7 @@ export interface ContactsListCacheContact { displayName: string remark?: string nickname?: string + alias?: string type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } @@ -1172,6 +1175,7 @@ export async function getContactsListCache(scopeKey: string): Promise { + const value = await config.get(CONFIG_KEYS.QUOTE_LAYOUT) + if (value === 'quote-bottom') return value + return 'quote-top' +} + +export async function setQuoteLayout(layout: QuoteLayout): Promise { + await config.set(CONFIG_KEYS.QUOTE_LAYOUT, layout) +} + // 获取词云排除词列表 export async function getWordCloudExcludeWords(): Promise { const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)