Merge branch 'dev' into fix/image-scan-helper-entitlement

解决冲突:在 dev 最新 Mach API 内存扫描方案基础上,保留 image_scan_helper
子进程作为优先路径(有 debugger entitlement),Mach API 作为 fallback。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felix
2026-03-13 22:42:59 +11:00
26 changed files with 1514 additions and 518 deletions

View File

@@ -9,6 +9,58 @@ permissions:
contents: write contents: write
jobs: jobs:
release-mac-arm64:
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22.12
cache: "npm"
- name: Install Dependencies
run: npm ci
- 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 macOS arm64 (unsigned DMG)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
npx electron-builder --mac dmg --arm64 --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
release: release:
runs-on: windows-latest runs-on: windows-latest

1
.gitignore vendored
View File

@@ -63,6 +63,7 @@ chatlab-format.md
*.bak *.bak
AGENTS.md AGENTS.md
.claude/ .claude/
CLAUDE.md
.agents/ .agents/
resources/wx_send resources/wx_send
概述.md 概述.md

View File

@@ -235,6 +235,23 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
return task?.canceled === true return task?.canceled === true
} }
const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
if (process.platform === 'darwin') {
win.setWindowButtonVisibility(false)
}
const emitMaximizeState = () => {
if (win.isDestroyed()) return
win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
}
win.on('maximize', emitMaximizeState)
win.on('unmaximize', emitMaximizeState)
win.on('enter-full-screen', emitMaximizeState)
win.on('leave-full-screen', emitMaximizeState)
win.webContents.on('did-finish-load', emitMaximizeState)
}
function createWindow(options: { autoShow?: boolean } = {}) { function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录 // 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options const { autoShow = true } = options
@@ -256,13 +273,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
webSecurity: false // Allow loading local files (video playback) webSecurity: false // Allow loading local files (video playback)
}, },
titleBarStyle: 'hidden', titleBarStyle: 'hidden',
titleBarOverlay: { titleBarOverlay: false,
color: '#00000000',
symbolColor: '#1a1a1a',
height: 40
},
show: false show: false
}) })
setupCustomTitleBarWindow(win)
// 窗口准备好后显示 // 窗口准备好后显示
// Splash 模式下不在这里 show由启动流程统一控制 // Splash 模式下不在这里 show由启动流程统一控制
@@ -710,15 +724,12 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
nodeIntegration: false nodeIntegration: false
}, },
titleBarStyle: 'hidden', titleBarStyle: 'hidden',
titleBarOverlay: { titleBarOverlay: false,
color: '#00000000',
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
height: 32
},
show: false, show: false,
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
autoHideMenuBar: true autoHideMenuBar: true
}) })
setupCustomTitleBarWindow(win)
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
win.show() win.show()
@@ -1103,6 +1114,11 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('window:isMaximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win?.isMaximized() || win?.isFullScreen())
})
ipcMain.on('window:close', (event) => { ipcMain.on('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close() BrowserWindow.fromWebContents(event.sender)?.close()
}) })
@@ -2002,7 +2018,6 @@ function registerIpcHandlers() {
dbPath, dbPath,
decryptKey, decryptKey,
wxid, wxid,
nativeTimeoutMs: 5000,
onProgress: (progress) => { onProgress: (progress) => {
if (isYearsLoadCanceled(taskId)) return if (isYearsLoadCanceled(taskId)) return
const snapshot = updateTaskSnapshot({ const snapshot = updateTaskSnapshot({

View File

@@ -86,6 +86,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
window: { window: {
minimize: () => ipcRenderer.send('window:minimize'), minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'), maximize: () => ipcRenderer.send('window:maximize'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
ipcRenderer.on('window:maximizeStateChanged', listener)
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
},
close: () => ipcRenderer.send('window:close'), close: () => ipcRenderer.send('window:close'),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),

View File

@@ -67,7 +67,10 @@ class CloudControlService {
} }
if (platform === 'darwin') { if (platform === 'darwin') {
return `macOS ${os.release()}` // `os.release()` returns Darwin kernel version (e.g. 25.3.0),
// while cloud reporting expects the macOS product version (e.g. 26.3).
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
return `macOS ${macVersion}`
} }
return platform return platform
@@ -92,4 +95,3 @@ class CloudControlService {
export const cloudControlService = new CloudControlService() export const cloudControlService = new CloudControlService()

View File

@@ -715,6 +715,68 @@ export class KeyService {
return wxid.substring(0, second) return wxid.substring(0, second)
} }
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (manualDir) {
const normalized = manualDir.replace(/[\\/]+$/, '')
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
const { readdirSync, statSync } = await import('fs')
const { join } = await import('path')
for (const entry of readdirSync(root)) {
if (!entry.startsWith('wxid_')) continue
const full = join(root, entry)
try {
if (statSync(full).isDirectory()) pushUnique(entry)
} catch { }
}
} catch { }
}
}
pushUnique('unknown')
return candidates
}
async autoGetImageKey( async autoGetImageKey(
manualDir?: string, manualDir?: string,
onProgress?: (message: string) => void, onProgress?: (message: string) => void,
@@ -750,52 +812,34 @@ export class KeyService {
const codes: number[] = accounts[0].keys.map((k: any) => k.code) const codes: number[] = accounts[0].keys.map((k: any) => k.code)
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid可能是unknown const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
let targetWxid = '' let verifyCiphertext: Buffer | null = null
if (manualDir && existsSync(manualDir)) {
// 方案1: 直接使用传入的wxidParam最优先 const template = await this._findTemplateData(manualDir, 32)
if (wxidParam && wxidParam.startsWith('wxid_')) { verifyCiphertext = template.ciphertext
targetWxid = wxidParam
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
} }
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid if (verifyCiphertext) {
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234" onProgress?.(`正在校验候选 wxid${wxidCandidates.length} 个)...`)
if (!targetWxid && manualDir) { for (const candidateWxid of wxidCandidates) {
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' for (const code of codes) {
if (dirName.startsWith('wxid_')) { const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
targetWxid = dirName if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid) onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
return { success: true, xorKey, aesKey }
} }
} }
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown
if (!targetWxid) {
targetWxid = accounts[0].wxid
console.log('[ImageKey] 无法获取 wxid使用 DLL 发现的:', targetWxid)
} }
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致 // 无模板密文可验真时回退旧策略
const cleanedWxid = this.cleanWxid(targetWxid) const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid) const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
// 用 cleanedWxid + code 本地计算密钥 onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
// xorKey = code & 0xFF console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16) return { success: true, xorKey, aesKey }
const code = codes[0]
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
return {
success: true,
xorKey,
aesKey
}
} }
// --- 内存扫描备选方案(融合 Dart+Python 优点)--- // --- 内存扫描备选方案(融合 Dart+Python 优点)---

View File

@@ -1,8 +1,10 @@
import { app, shell } from 'electron' import { app, shell } from 'electron'
import { join } from 'path' import { join, basename, dirname } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import crypto from 'crypto'
import { homedir } from 'os'
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
@@ -14,9 +16,13 @@ export class KeyServiceMac {
private initialized = false private initialized = false
private GetDbKey: any = null private GetDbKey: any = null
private ScanMemoryForImageKey: any = null
private FreeString: any = null
private ListWeChatProcesses: any = null private ListWeChatProcesses: any = null
private libSystem: any = null
private machTaskSelf: any = null
private taskForPid: any = null
private machVmRegion: any = null
private machVmReadOverwrite: any = null
private machPortDeallocate: any = null
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
@@ -101,8 +107,6 @@ export class KeyServiceMac {
this.lib = this.koffi.load(dylibPath) this.lib = this.koffi.load(dylibPath)
this.GetDbKey = this.lib.func('const char* GetDbKey()') this.GetDbKey = this.lib.func('const char* GetDbKey()')
this.ScanMemoryForImageKey = this.lib.func('const char* ScanMemoryForImageKey(int pid, const char* ciphertext)')
this.FreeString = this.lib.func('void FreeString(const char* str)')
this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()')
this.initialized = true this.initialized = true
@@ -117,30 +121,18 @@ export class KeyServiceMac {
): Promise<DbKeyResult> { ): Promise<DbKeyResult> {
try { try {
onStatus?.('正在获取数据库密钥...', 0) onStatus?.('正在获取数据库密钥...', 0)
let parsed = await this.getDbKeyParsed(timeoutMs, onStatus) onStatus?.('正在请求管理员授权并执行 helper...', 0)
console.log('[KeyServiceMac] GetDbKey returned:', parsed.raw) let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
try {
// ATTACH_FAILED 时自动走图形化授权,再重试一次 const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus)
if (!parsed.success && parsed.code === 'ATTACH_FAILED') { parsed = this.parseDbKeyResult(elevatedResult)
onStatus?.('检测到调试权限不足,正在请求系统授权...', 0) console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw)
const permissionOk = await this.enableDebugPermissionWithPrompt() } catch (e: any) {
if (permissionOk) { const msg = `${e?.message || e}`
onStatus?.('授权完成,正在重试获取密钥...', 0) if (msg.includes('(-128)') || msg.includes('User canceled')) {
parsed = await this.getDbKeyParsed(timeoutMs, onStatus) return { success: false, error: '已取消管理员授权' }
console.log('[KeyServiceMac] GetDbKey retry returned:', parsed.raw)
} else {
onStatus?.('已取消系统授权', 2)
return { success: false, error: '已取消系统授权' }
} }
} throw e
if (!parsed.success && parsed.code === 'ATTACH_FAILED') {
// DevToolsSecurity 仍不足时,自动拉起开发者工具权限页面
await this.openDeveloperToolsPrivacySettings()
await this.revealCurrentExecutableInFinder()
const msg = `无法附加到微信进程。已打开“开发者工具”设置,并在访达中定位当前运行程序。\n请在“隐私与安全性 -> 开发者工具”点击“+”添加并允许:${process.execPath}`
onStatus?.(msg, 2)
return { success: false, error: msg }
} }
if (!parsed.success) { if (!parsed.success) {
@@ -177,15 +169,55 @@ export class KeyServiceMac {
timeoutMs: number, timeoutMs: number,
onStatus?: (message: string, level: number) => void onStatus?: (message: string, level: number) => void
): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> {
try {
const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus)
return this.parseDbKeyResult(helperResult) return this.parseDbKeyResult(helperResult)
} catch (e: any) {
console.warn('[KeyServiceMac] helper unavailable, fallback to dylib:', e?.message || e)
if (!this.initialized) {
await this.initialize()
} }
return this.parseDbKeyResult(this.GetDbKey())
private async getWeChatPid(): Promise<number> {
try {
// 优先使用 pgrep避免 ps 的 comm 列被截断导致识别失败
try {
const { stdout } = await execFileAsync('pgrep', ['-x', 'WeChat'])
const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0)
if (ids.length > 0) return Math.max(...ids)
} catch {
// ignore and fallback to ps
}
const { stdout } = await execFileAsync('ps', ['-A', '-o', 'pid,comm,command'])
const lines = stdout.split('\n').slice(1)
const candidates: Array<{ pid: number; command: string }> = []
for (const line of lines) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/)
if (!match) continue
const pid = parseInt(match[1], 10)
const command = match[3]
const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') ||
command.includes('/Contents/MacOS/WeChat')
if (pathMatch) candidates.push({ pid, command })
}
if (candidates.length === 0) throw new Error('WeChat process not found')
const filtered = candidates.filter(p => {
const cmd = p.command
return !cmd.includes('WeChatAppEx.app/') &&
!cmd.includes('/WeChatAppEx') &&
!cmd.includes(' WeChatAppEx') &&
!cmd.includes('crashpad_handler') &&
!cmd.includes('Helper')
})
if (filtered.length === 0) throw new Error('No valid WeChat main process found')
const preferredMain = filtered.filter(p => p.command.includes('/Contents/MacOS/WeChat'))
const selectedPool = preferredMain.length > 0 ? preferredMain : filtered
const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max)
return selected.pid
} catch (e: any) {
throw new Error('Failed to get WeChat PID: ' + e.message)
} }
} }
@@ -195,8 +227,14 @@ export class KeyServiceMac {
): Promise<string> { ): Promise<string> {
const helperPath = this.getHelperPath() const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000) const waitMs = Math.max(timeoutMs, 30_000)
const pid = await this.getWeChatPid()
onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0)
// 最佳努力清理同路径残留 helper普通权限
try { await execFileAsync('pkill', ['-f', helperPath], { timeout: 2000 }) } catch { }
return await new Promise<string>((resolve, reject) => { return await new Promise<string>((resolve, reject) => {
const child = spawn(helperPath, [String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) // xkey_helper 参数协议helper <pid> [timeout_ms]
const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = '' let stdout = ''
let stderr = '' let stderr = ''
let stdoutBuf = '' let stdoutBuf = ''
@@ -295,6 +333,51 @@ export class KeyServiceMac {
}) })
} }
private shellSingleQuote(text: string): string {
return `'${String(text).replace(/'/g, `'\\''`)}'`
}
private async getDbKeyByHelperElevated(
timeoutMs: number,
onStatus?: (message: string, level: number) => void
): Promise<string> {
const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000)
const pid = await this.getWeChatPid()
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
'do shell script cmd with administrator privileges'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
let stdout = ''
try {
const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: waitMs + 20_000
})
stdout = result.stdout || ''
} catch (e: any) {
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
throw new Error(msg || 'elevated helper execution failed')
}
const lines = String(stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
const last = lines[lines.length - 1]
if (!last) throw new Error('elevated helper returned empty output')
let payload: any
try {
payload = JSON.parse(last)
} catch {
throw new Error('elevated helper returned invalid json: ' + last)
}
if (payload?.success === true && typeof payload?.key === 'string') return payload.key
if (typeof payload?.result === 'string') return payload.result
throw new Error('elevated helper json missing key/result')
}
private mapDbKeyErrorMessage(code?: string, detail?: string): string { private mapDbKeyErrorMessage(code?: string, detail?: string): string {
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
if (code === 'ATTACH_FAILED') { if (code === 'ATTACH_FAILED') {
@@ -371,18 +454,52 @@ export class KeyServiceMac {
onStatus?: (message: string) => void, onStatus?: (message: string) => void,
wxid?: string wxid?: string
): Promise<ImageKeyResult> { ): Promise<ImageKeyResult> {
onStatus?.('macOS 请使用内存扫描方式') try {
return { success: false, error: 'macOS 请使用内存扫描方式' } onStatus?.('正在从缓存目录扫描图片密钥...')
const codes = this.collectKvcommCodes(accountPath)
if (codes.length === 0) {
return { success: false, error: '未找到有效的密钥码kvcomm 缓存为空)' }
}
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
if (wxidCandidates.length === 0) {
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
}
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
let verifyCiphertext: Buffer | null = null
if (accountPath && existsSync(accountPath)) {
const template = await this._findTemplateData(accountPath, 32)
verifyCiphertext = template.ciphertext
}
if (verifyCiphertext) {
onStatus?.(`正在校验候选 wxid${wxidCandidates.length} 个)...`)
for (const candidateWxid of wxidCandidates) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
return { success: true, xorKey, aesKey }
}
}
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
}
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code
const fallbackWxid = wxidCandidates[0]
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
return { success: true, xorKey, aesKey }
} catch (e: any) {
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
}
} }
async autoGetImageKeyByMemoryScan( async autoGetImageKeyByMemoryScan(
userDir: string, userDir: string,
onProgress?: (message: string) => void onProgress?: (message: string) => void
): Promise<ImageKeyResult> { ): Promise<ImageKeyResult> {
if (!this.initialized) {
await this.initialize()
}
try { try {
// 1. 查找模板文件获取密文和 XOR 密钥 // 1. 查找模板文件获取密文和 XOR 密钥
onProgress?.('正在查找模板文件...') onProgress?.('正在查找模板文件...')
@@ -412,7 +529,7 @@ export class KeyServiceMac {
while (Date.now() < deadline) { while (Date.now() < deadline) {
scanCount++ scanCount++
onProgress?.(`${scanCount} 次扫描内存,请在微信中打开图片大图...`) onProgress?.(`${scanCount} 次扫描内存,请在微信中打开图片大图...`)
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext) const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
if (aesKey) { if (aesKey) {
onProgress?.('密钥获取成功') onProgress?.('密钥获取成功')
return { success: true, xorKey, aesKey } return { success: true, xorKey, aesKey }
@@ -481,12 +598,46 @@ export class KeyServiceMac {
return { ciphertext, xorKey } return { ciphertext, xorKey }
} }
private async _scanMemoryForAesKey(pid: number, ciphertext: Buffer): Promise<string | null> { private ensureMachApis(): boolean {
const ciphertextHex = ciphertext.toString('hex') if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true
try {
if (!this.koffi) this.koffi = require('koffi')
this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib')
this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', [])
this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')])
this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [
'uint32',
this.koffi.out('uint64*'),
this.koffi.out('uint64*'),
'int',
'void*',
this.koffi.out('uint32*'),
this.koffi.out('uint32*')
])
this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [
'uint32',
'uint64',
'uint64',
'void*',
this.koffi.out('uint64*')
])
this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32'])
return true
} catch (e) {
console.error('[KeyServiceMac] 初始化 Mach API 失败:', e)
return false
}
}
private async _scanMemoryForAesKey(
pid: number,
ciphertext: Buffer,
onProgress?: (message: string) => void
): Promise<string | null> {
// 优先通过 image_scan_helper 子进程调用(有 debugger entitlement // 优先通过 image_scan_helper 子进程调用(有 debugger entitlement
try { try {
const helperPath = this.getImageScanHelperPath() const helperPath = this.getImageScanHelperPath()
const ciphertextHex = ciphertext.toString('hex')
const result = await new Promise<string | null>((resolve, reject) => { const result = await new Promise<string | null>((resolve, reject) => {
const child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] }) const child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = '' let stdout = ''
@@ -506,15 +657,104 @@ export class KeyServiceMac {
}) })
setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, 30_000) setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, 30_000)
}) })
return result if (result) return result
} catch (e: any) { } catch (e: any) {
console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to dylib:', e?.message) console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message)
} }
// fallback: 直接调 dylibElectron 进程可能没有 task_for_pid 权限) // fallback: 直接通过 Mach API 扫描内存Electron 进程可能没有 task_for_pid 权限)
if (!this.initialized) await this.initialize() if (!this.ensureMachApis()) return null
const aesKey = this.ScanMemoryForImageKey(pid, ciphertextHex)
return aesKey || null const VM_PROT_READ = 0x1
const VM_PROT_WRITE = 0x2
const VM_REGION_BASIC_INFO_64 = 9
const VM_REGION_BASIC_INFO_COUNT_64 = 9
const KERN_SUCCESS = 0
const MAX_REGION_SIZE = 50 * 1024 * 1024
const CHUNK = 4 * 1024 * 1024
const OVERLAP = 65
const selfTask = this.machTaskSelf()
const taskBuf = Buffer.alloc(4)
const attachKr = this.taskForPid(selfTask, pid, taskBuf)
const task = taskBuf.readUInt32LE(0)
if (attachKr !== KERN_SUCCESS || !task) return null
try {
const regions: Array<[number, number]> = []
let address = 0
while (address < 0x7FFFFFFFFFFF) {
const addrBuf = Buffer.alloc(8)
addrBuf.writeBigUInt64LE(BigInt(address), 0)
const sizeBuf = Buffer.alloc(8)
const infoBuf = Buffer.alloc(64)
const countBuf = Buffer.alloc(4)
countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0)
const objectBuf = Buffer.alloc(4)
const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf)
if (kr !== KERN_SUCCESS) break
const base = Number(addrBuf.readBigUInt64LE(0))
const size = Number(sizeBuf.readBigUInt64LE(0))
const protection = infoBuf.readInt32LE(0)
const objectName = objectBuf.readUInt32LE(0)
if (objectName) {
try { this.machPortDeallocate(selfTask, objectName) } catch { }
}
if ((protection & VM_PROT_READ) !== 0 &&
(protection & VM_PROT_WRITE) !== 0 &&
size > 0 &&
size <= MAX_REGION_SIZE) {
regions.push([base, size])
}
const next = base + size
if (next <= address) break
address = next
}
const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
for (let ri = 0; ri < regions.length; ri++) {
const [base, size] = regions[ri]
if (ri % 20 === 0) {
onProgress?.(`扫描进度 ${ri}/${regions.length}...`)
await new Promise(r => setTimeout(r, 1))
}
let offset = 0
let trailing: Buffer | null = null
while (offset < size) {
const chunkSize = Math.min(CHUNK, size - offset)
const chunk = Buffer.alloc(chunkSize)
const outSizeBuf = Buffer.alloc(8)
const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf)
const bytesRead = Number(outSizeBuf.readBigUInt64LE(0))
offset += chunkSize
if (kr !== KERN_SUCCESS || bytesRead <= 0) {
trailing = null
continue
}
const current = chunk.subarray(0, bytesRead)
const data = trailing ? Buffer.concat([trailing, current]) : current
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
if (key) return key
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
const fallbackKey = this._searchAny16Key(data, ciphertext)
if (fallbackKey) return fallbackKey
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
}
}
return null
} finally {
try { this.machPortDeallocate(selfTask, task) } catch { }
}
} }
private async findWeChatPid(): Promise<number | null> { private async findWeChatPid(): Promise<number | null> {
@@ -531,5 +771,224 @@ export class KeyServiceMac {
cleanup(): void { cleanup(): void {
this.lib = null this.lib = null
this.initialized = false this.initialized = false
this.libSystem = null
this.machTaskSelf = null
this.taskForPid = null
this.machVmRegion = null
this.machVmReadOverwrite = null
this.machPortDeallocate = null
}
private cleanWxid(wxid: string): string {
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
if (second === -1) return wxid
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16)
return { xorKey, aesKey }
}
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
// 1) 显式传参优先
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (accountPath) {
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
const dirName = basename(normalized)
// 2) 当前目录名为 wxid_*
if (dirName.startsWith('wxid_')) pushUnique(dirName)
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker)
if (markerIdx >= 0) {
const root = normalized.slice(0, markerIdx + marker.length)
if (existsSync(root)) {
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (!entry.name.startsWith('wxid_')) continue
pushUnique(entry.name)
}
} catch {
// ignore
}
}
}
}
pushUnique('unknown')
return candidates
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private collectKvcommCodes(accountPath?: string): number[] {
const codeSet = new Set<number>()
const pattern = /^key_(\d+)_.+\.statistic$/i
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
if (!existsSync(kvcommDir)) continue
try {
const files = readdirSync(kvcommDir)
for (const file of files) {
const match = file.match(pattern)
if (!match) continue
const code = Number(match[1])
if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue
codeSet.add(code)
}
} catch {
// 忽略不可读目录,继续尝试其他候选路径
}
}
return Array.from(codeSet)
}
private getKvcommCandidates(accountPath?: string): string[] {
const home = homedir()
const candidates = new Set<string>([
// 与用户实测路径一致Documents/xwechat_files -> Documents/app_data/net/kvcomm
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'),
join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm')
])
if (accountPath) {
// 规则:把路径中的 xwechat_files 替换为 app_data然后拼 net/kvcomm
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
const marker = '/xwechat_files'
const idx = normalized.indexOf(marker)
if (idx >= 0) {
const base = normalized.slice(0, idx)
candidates.add(`${base}/app_data/net/kvcomm`)
}
let cursor = accountPath
for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm'))
const next = dirname(cursor)
if (next === cursor) break
cursor = next
}
}
return Array.from(candidates)
}
private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 34; i++) {
if (this._isAlphaNum(data[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
}
if (!valid) continue
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
const keyBytes = data.subarray(i + 1, i + 33)
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 65; i++) {
let valid = true
for (let j = 0; j < 32; j++) {
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
}
if (!valid) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _isAlphaNum(b: number): boolean {
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
}
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
// 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率
private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i + 16 <= data.length; i++) {
const keyBytes = data.subarray(i, i + 16)
if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue
if (!this._isMostlyPrintableAscii(keyBytes)) continue
return keyBytes.toString('ascii')
}
return null
}
private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean {
let printable = 0
for (const b of keyBytes16) {
if (b >= 0x20 && b <= 0x7E) printable++
}
return printable >= 14
} }
} }

View File

@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
private recognizer: OfflineRecognizer | null = null private recognizer: OfflineRecognizer | null = null
private isInitializing = false private isInitializing = false
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env }
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const candidates = [
join(__dirname, '..', 'node_modules', platformPkg),
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter((item): item is string => Boolean(item) && existsSync(item))
if (process.platform === 'darwin') {
const key = 'DYLD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'linux') {
const key = 'LD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env
}
private resolveModelDir(): string { private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured if (configured) return configured
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
} }
} }
const { Worker } = require('worker_threads') const { fork } = require('child_process')
const workerPath = join(__dirname, 'transcribeWorker.js') const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, { const worker = fork(workerPath, [], {
workerData: { env: this.buildTranscribeWorkerEnv(),
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
serialization: 'advanced'
})
worker.send({
modelPath, modelPath,
tokensPath, tokensPath,
wavData, wavData,
sampleRate: 16000, sampleRate: 16000,
languages: supportedLanguages languages: supportedLanguages
}
}) })
let finalTranscript = '' let finalTranscript = ''
@@ -227,11 +262,13 @@ export class VoiceTranscribeService {
} else if (msg.type === 'final') { } else if (msg.type === 'final') {
finalTranscript = msg.text finalTranscript = msg.text
resolve({ success: true, transcript: finalTranscript }) resolve({ success: true, transcript: finalTranscript })
worker.terminate() worker.disconnect()
worker.kill()
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error) console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error }) resolve({ success: false, error: msg.error })
worker.terminate() worker.disconnect()
worker.kill()
} }
}) })

View File

@@ -148,13 +148,8 @@ export class WcdbCore {
} }
} }
// 使用命名管道 IPC (Windows) // 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket)
startMonitor(callback: (type: string, json: string) => void): boolean { startMonitor(callback: (type: string, json: string) => void): boolean {
if (process.platform !== 'win32') {
console.warn('[wcdbCore] Monitor not supported on macOS')
return false
}
if (!this.wcdbStartMonitorPipe) { if (!this.wcdbStartMonitorPipe) {
return false return false
} }

View File

@@ -1,13 +1,56 @@
import { parentPort, workerData } from 'worker_threads' import { parentPort, workerData } from 'worker_threads'
import { existsSync } from 'fs'
import { join } from 'path'
interface WorkerParams { interface WorkerParams {
modelPath: string modelPath: string
tokensPath: string tokensPath: string
wavData: Buffer wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
sampleRate: number sampleRate: number
languages?: string[] languages?: string[]
} }
function appendLibrarySearchPath(libDir: string): void {
if (!existsSync(libDir)) return
if (process.platform === 'darwin') {
const current = process.env.DYLD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
return
}
if (process.platform === 'linux') {
const current = process.env.LD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
}
}
function prepareSherpaRuntimeEnv(): void {
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const resourcesPath = (process as any).resourcesPath as string | undefined
const candidates = [
// Dev: /project/dist-electron -> /project/node_modules/...
join(__dirname, '..', 'node_modules', platformPkg),
// Fallback for alternate layouts
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
// Packaged app: Resources/app.asar.unpacked/node_modules/...
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter(Boolean)
for (const dir of candidates) {
appendLibrarySearchPath(dir)
}
}
// 语言标记映射 // 语言标记映射
const LANGUAGE_TAGS: Record<string, string> = { const LANGUAGE_TAGS: Record<string, string> = {
'zh': '<|zh|>', 'zh': '<|zh|>',
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
} }
async function run() { async function run() {
if (!parentPort) { const isForkProcess = !parentPort
return; const emit = (msg: any) => {
if (parentPort) {
parentPort.postMessage(msg)
return
}
if (typeof process.send === 'function') {
process.send(msg)
}
}
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
if (Buffer.isBuffer(data)) return data
if (data instanceof Uint8Array) return Buffer.from(data)
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
return Buffer.from((data as any).data)
}
return Buffer.alloc(0)
}
const readParams = async (): Promise<WorkerParams | null> => {
if (parentPort) {
return workerData as WorkerParams
}
return new Promise((resolve) => {
let settled = false
const finish = (value: WorkerParams | null) => {
if (settled) return
settled = true
resolve(value)
}
process.once('message', (msg) => finish(msg as WorkerParams))
process.once('disconnect', () => finish(null))
})
} }
try { try {
prepareSherpaRuntimeEnv()
const params = await readParams()
if (!params) return
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等) // 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
let sherpa: any; let sherpa: any;
try { try {
sherpa = require('sherpa-onnx-node'); sherpa = require('sherpa-onnx-node');
} catch (requireError) { } catch (requireError) {
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
if (isForkProcess) process.exit(1)
return; return;
} }
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
const wavData = Buffer.from(rawWavData); const wavData = normalizeBuffer(rawWavData);
// 确保有有效的语言列表,默认只允许中文 // 确保有有效的语言列表,默认只允许中文
let allowedLanguages = languages || ['zh'] let allowedLanguages = languages || ['zh']
if (allowedLanguages.length === 0) { if (allowedLanguages.length === 0) {
@@ -151,16 +232,18 @@ async function run() {
if (isLanguageAllowed(result, allowedLanguages)) { if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text) const processedText = richTranscribePostProcess(result.text)
parentPort.postMessage({ type: 'final', text: processedText }) emit({ type: 'final', text: processedText })
if (isForkProcess) process.exit(0)
} else { } else {
parentPort.postMessage({ type: 'final', text: '' }) emit({ type: 'final', text: '' })
if (isForkProcess) process.exit(0)
} }
} catch (error) { } catch (error) {
parentPort.postMessage({ type: 'error', error: String(error) }) emit({ type: 'error', error: String(error) })
if (isForkProcess) process.exit(1)
} }
} }
run(); run();

View File

@@ -129,6 +129,8 @@
"asarUnpack": [ "asarUnpack": [
"node_modules/silk-wasm/**/*", "node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*", "node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*"
], ],
"extraFiles": [ "extraFiles": [

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -138,10 +138,6 @@ function App() {
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
document.documentElement.setAttribute('data-theme', currentTheme) document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', effectiveMode) document.documentElement.setAttribute('data-mode', effectiveMode)
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
} }
applyMode(themeMode) applyMode(themeMode)

View File

@@ -61,6 +61,16 @@
gap: 4px; gap: 4px;
padding: 6px; padding: 6px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
opacity: 0;
transform: translateY(8px) scale(0.95);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
&.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
} }
.sidebar-user-menu-item { .sidebar-user-menu-item {
@@ -265,6 +275,185 @@
gap: 4px; gap: 4px;
} }
.sidebar-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sidebar-dialog {
width: min(420px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sidebar-wxid-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.sidebar-wxid-item {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.current {
border-color: rgba(99, 102, 241, 0.5);
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wxid-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-size: 16px;
font-weight: 600;
}
}
.wxid-info {
flex: 1;
min-width: 0;
text-align: left;
}
.wxid-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wxid-id {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
padding: 4px 10px;
border-radius: 6px;
background: var(--primary);
color: var(--on-primary);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
}
.sidebar-dialog-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
.sidebar-clear-dialog-overlay { .sidebar-clear-dialog-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -274,6 +463,7 @@
justify-content: center; justify-content: center;
z-index: 1100; z-index: 1100;
padding: 20px; padding: 20px;
animation: fadeIn 0.2s ease;
} }
.sidebar-clear-dialog { .sidebar-clear-dialog {
@@ -283,6 +473,7 @@
border-radius: 16px; border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px; padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 { h3 {
margin: 0; margin: 0;

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react' import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import * as configService from '../services/config' import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -15,11 +17,28 @@ interface SidebarUserProfile {
} }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
interface SidebarUserProfileCache extends SidebarUserProfile { interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number updatedAt: number
} }
interface AccountProfilesCache {
[wxid: string]: {
displayName: string
avatarUrl?: string
alias?: string
updatedAt: number
}
}
interface WxidOption {
wxid: string
modifiedTime: number
displayName?: string
avatarUrl?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => { const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try { try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
@@ -46,11 +65,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
updatedAt: Date.now() updatedAt: Date.now()
} }
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
// 同时写入账号缓存池
const accountsCache = readAccountProfilesCache()
accountsCache[profile.wxid] = {
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
alias: profile.alias,
updatedAt: Date.now()
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
} catch { } catch {
// 忽略本地缓存失败,不影响主流程 // 忽略本地缓存失败,不影响主流程
} }
} }
const readAccountProfilesCache = (): AccountProfilesCache => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed ? parsed : {}
} catch {
return {}
}
}
const normalizeAccountId = (value?: string | null): string => { const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim() const trimmed = String(value || '').trim()
if (!trimmed) return '' if (!trimmed) return ''
@@ -76,12 +116,14 @@ function Sidebar({ collapsed }: SidebarProps) {
displayName: '未识别用户' displayName: '未识别用户'
}) })
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false) const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
const [shouldClearCacheData, setShouldClearCacheData] = useState(false) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [shouldClearExportData, setShouldClearExportData] = useState(false) const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null) const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked) const setLocked = useAppStore(state => state.setLocked)
const isDbConnected = useAppStore(state => state.isDbConnected)
const resetChatStore = useChatStore(state => state.reset)
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
useEffect(() => { useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
@@ -143,6 +185,9 @@ function Sidebar({ collapsed }: SidebarProps) {
const resolvedWxidRaw = String(wxid || '').trim() const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw) const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) return
const wxidCandidates = new Set<string>([ const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(), resolvedWxidRaw.toLowerCase(),
resolvedWxid.trim().toLowerCase(), resolvedWxid.trim().toLowerCase(),
@@ -168,77 +213,36 @@ function Sidebar({ collapsed }: SidebarProps) {
return undefined return undefined
} }
const fallbackDisplayName = resolvedWxid || '未识别用户' // 并行获取名称和头像
const [contactResult, avatarResult] = await Promise.allSettled([
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 (async () => {
patchUserProfile({ const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
wxid: resolvedWxid, for (const candidate of candidates) {
displayName: fallbackDisplayName
})
if (!resolvedWxidRaw && !resolvedWxid) return
// 第二阶段:后台补齐名称(不会阻塞首屏)。
void (async () => {
try {
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
const contact = await window.electronAPI.chat.getContact(candidate) const contact = await window.electronAPI.chat.getContact(candidate)
if (!contact) continue if (contact?.remark || contact?.nickName || contact?.alias) {
if (!myContact) myContact = contact return contact
if (contact.remark || contact.nickName || contact.alias) {
myContact = contact
break
} }
} }
const fromContact = pickFirstValidName( return null
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark, myContact?.remark,
myContact?.nickName, myContact?.nickName,
myContact?.alias myContact?.alias
) ) || resolvedWxid || '未识别用户'
if (fromContact) { patchUserProfile({
patchUserProfile({ displayName: fromContact }, resolvedWxid) wxid: resolvedWxid,
// 同步补充微信号alias displayName,
if (myContact?.alias) { alias: myContact?.alias,
patchUserProfile({ alias: myContact.alias }, resolvedWxid) avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
} ? avatarResult.value.avatarUrl
return : undefined
} })
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
const enrichedDisplayName = pickFirstValidName(
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
enrichedResult.contacts?.[resolvedWxid]?.displayName,
enrichedResult.contacts?.[cleanedWxid]?.displayName,
enrichedResult.contacts?.self?.displayName,
myContact?.alias
)
const bestName = enrichedDisplayName
if (bestName) {
patchUserProfile({ displayName: bestName }, resolvedWxid)
}
// 降级分支也补充微信号
if (myContact?.alias) {
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
}
} catch (nameError) {
console.error('加载侧边栏用户昵称失败:', nameError)
}
})()
// 第二阶段:后台补齐头像(不会阻塞首屏)。
void (async () => {
try {
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
}
} catch (avatarError) {
console.error('加载侧边栏用户头像失败:', avatarError)
}
})()
} catch (error) { } catch (error) {
console.error('加载侧边栏用户信息失败:', error) console.error('加载侧边栏用户信息失败:', error)
} }
@@ -246,10 +250,7 @@ function Sidebar({ collapsed }: SidebarProps) {
const cachedProfile = readSidebarUserProfileCache() const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) { if (cachedProfile) {
setUserProfile(prev => ({ setUserProfile(cachedProfile)
...prev,
...cachedProfile
}))
} }
void loadCurrentUser() void loadCurrentUser()
@@ -263,23 +264,107 @@ function Sidebar({ collapsed }: SidebarProps) {
return [...name][0] || '?' return [...name][0] || '?'
} }
const isActive = (path: string) => { const openSwitchAccountDialog = async () => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
const canConfirmClear = shouldClearCacheData || shouldClearExportData
const resetClearDialogState = () => {
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(false)
}
const openClearAccountDialog = () => {
setIsAccountMenuOpen(false) setIsAccountMenuOpen(false)
setShouldClearCacheData(false) if (!isDbConnected) {
setShouldClearExportData(false) window.alert('数据库未连接,无法切换账号')
setShowClearAccountDialog(true) return
}
const dbPath = await configService.getDbPath()
if (!dbPath) {
window.alert('请先在设置中配置数据库路径')
return
}
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
const accountsCache = readAccountProfilesCache()
console.log('[切换账号] 账号缓存:', accountsCache)
const enrichedWxids = wxids.map(option => {
const normalizedWxid = normalizeAccountId(option.wxid)
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
return {
...option,
displayName: userProfile.displayName,
avatarUrl: userProfile.avatarUrl
}
}
if (cached) {
console.log('[切换账号] 使用缓存:', option.wxid, cached)
return {
...option,
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
}
}
return { ...option, displayName: option.wxid }
})
setWxidOptions(enrichedWxids)
setShowSwitchAccountDialog(true)
} catch (error) {
console.error('扫描账号失败:', error)
window.alert('扫描账号失败,请稍后重试')
}
}
const handleSwitchAccount = async (selectedWxid: string) => {
if (!selectedWxid || isSwitchingAccount) return
setIsSwitchingAccount(true)
try {
console.log('[切换账号] 开始切换到:', selectedWxid)
const currentWxid = userProfile.wxid
if (currentWxid === selectedWxid) {
console.log('[切换账号] 已经是当前账号,跳过')
setShowSwitchAccountDialog(false)
setIsSwitchingAccount(false)
return
}
console.log('[切换账号] 设置新 wxid')
await configService.setMyWxid(selectedWxid)
console.log('[切换账号] 获取账号配置')
const wxidConfig = await configService.getWxidConfig(selectedWxid)
console.log('[切换账号] 配置内容:', wxidConfig)
if (wxidConfig?.decryptKey) {
console.log('[切换账号] 设置 decryptKey')
await configService.setDecryptKey(wxidConfig.decryptKey)
}
if (typeof wxidConfig?.imageXorKey === 'number') {
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
await configService.setImageXorKey(wxidConfig.imageXorKey)
}
if (wxidConfig?.imageAesKey) {
console.log('[切换账号] 设置 imageAesKey')
await configService.setImageAesKey(wxidConfig.imageAesKey)
}
console.log('[切换账号] 检查数据库连接状态')
console.log('[切换账号] 数据库连接状态:', isDbConnected)
if (isDbConnected) {
console.log('[切换账号] 关闭数据库连接')
await window.electronAPI.chat.close()
}
console.log('[切换账号] 清除缓存')
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
console.log('[切换账号] 触发 wxid-changed 事件')
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
console.log('[切换账号] 切换成功')
setShowSwitchAccountDialog(false)
} catch (error) {
console.error('[切换账号] 失败:', error)
window.alert('切换账号失败,请稍后重试')
} finally {
setIsSwitchingAccount(false)
}
} }
const openSettingsFromAccountMenu = () => { const openSettingsFromAccountMenu = () => {
@@ -291,60 +376,13 @@ function Sidebar({ collapsed }: SidebarProps) {
}) })
} }
const handleConfirmClearAccountData = async () => { const isActive = (path: string) => {
if (!canConfirmClear || isClearingAccountData) return return location.pathname === path || location.pathname.startsWith(`${path}/`)
setIsClearingAccountData(true)
try {
const result = await window.electronAPI.chat.clearCurrentAccountData({
clearCache: shouldClearCacheData,
clearExports: shouldClearExportData
})
if (!result.success) {
window.alert(result.error || '清理失败,请稍后重试。')
return
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
setUserProfile({ wxid: '', displayName: '未识别用户' })
window.dispatchEvent(new Event('wxid-changed'))
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
const selectedScopes = [
shouldClearCacheData ? '缓存数据' : '',
shouldClearExportData ? '导出数据' : ''
].filter(Boolean)
const detailLines: string[] = [
`清理范围:${selectedScopes.join('、') || '未选择'}`,
`已清理项目:${removedPaths.length}`
]
if (removedPaths.length > 0) {
detailLines.push('', '清理明细(最多显示 8 项):')
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
detailLines.push(`${index + 1}. ${path}`)
}
if (removedPaths.length > 8) {
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
}
}
if (result.warning) {
detailLines.push('', `注意:${result.warning}`)
}
const followupHint = shouldClearCacheData
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
: '你可以继续使用当前登录状态,无需重新登录。'
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
resetClearDialogState()
if (shouldClearCacheData) {
window.location.reload()
}
} catch (error) {
console.error('清理账号数据失败:', error)
window.alert('清理失败,请稍后重试。')
} finally {
setIsClearingAccountData(false)
}
} }
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
return ( return (
<>
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}> <aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu"> <nav className="nav-menu">
{/* 首页 */} {/* 首页 */}
@@ -450,8 +488,16 @@ function Sidebar({ collapsed }: SidebarProps) {
</button> </button>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}> <div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && ( <div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<div className="sidebar-user-menu" role="menu" aria-label="账号菜单"> <button
className="sidebar-user-menu-item"
onClick={openSwitchAccountDialog}
type="button"
role="menuitem"
>
<RefreshCw size={14} />
<span></span>
</button>
<button <button
className="sidebar-user-menu-item" className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu} onClick={openSettingsFromAccountMenu}
@@ -461,17 +507,7 @@ function Sidebar({ collapsed }: SidebarProps) {
<Settings size={14} /> <Settings size={14} />
<span></span> <span></span>
</button> </button>
<button
className="sidebar-user-menu-item danger"
onClick={openClearAccountDialog}
type="button"
role="menuitem"
>
<Trash2 size={14} />
<span></span>
</button>
</div> </div>
)}
<div <div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`} className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined} title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
@@ -500,50 +536,40 @@ function Sidebar({ collapsed }: SidebarProps) {
</div> </div>
</div> </div>
</div> </div>
</aside>
{showClearAccountDialog && ( {showSwitchAccountDialog && (
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}> <div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}> <div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3> <h3></h3>
<p> <p></p>
weflow <div className="sidebar-wxid-list">
weflow {wxidOptions.map((option) => (
</p>
<div className="sidebar-clear-options">
<label>
<input
type="checkbox"
checked={shouldClearCacheData}
onChange={(event) => setShouldClearCacheData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
<label>
<input
type="checkbox"
checked={shouldClearExportData}
onChange={(event) => setShouldClearExportData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
</div>
<div className="sidebar-clear-actions">
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}></button>
<button <button
key={option.wxid}
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
onClick={() => handleSwitchAccount(option.wxid)}
disabled={isSwitchingAccount}
type="button" type="button"
className="danger"
disabled={!canConfirmClear || isClearingAccountData}
onClick={handleConfirmClearAccountData}
> >
{isClearingAccountData ? '清除中...' : '确认清除'} <div className="wxid-avatar">
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
</div>
<div className="wxid-info">
<div className="wxid-name">{option.displayName || option.wxid}</div>
<div className="wxid-id">{option.wxid}</div>
</div>
{userProfile.wxid === option.wxid && <span className="current-badge"></span>}
</button> </button>
))}
</div>
<div className="sidebar-dialog-actions">
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}></button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</aside> </>
) )
} }

View File

@@ -3,6 +3,7 @@
background: var(--bg-secondary); background: var(--bg-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -57,3 +58,35 @@
color: var(--text-primary); color: var(--text-primary);
} }
} }
.title-window-controls {
display: inline-flex;
align-items: center;
gap: 6px;
-webkit-app-region: no-drag;
}
.title-window-control-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.is-close:hover {
background: #e5484d;
color: #fff;
}
}

View File

@@ -1,13 +1,34 @@
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { useEffect, useState } from 'react'
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
import './TitleBar.scss' import './TitleBar.scss'
interface TitleBarProps { interface TitleBarProps {
title?: string title?: string
sidebarCollapsed?: boolean sidebarCollapsed?: boolean
onToggleSidebar?: () => void onToggleSidebar?: () => void
showWindowControls?: boolean
} }
function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) { function TitleBar({
title,
sidebarCollapsed = false,
onToggleSidebar,
showWindowControls = true
}: TitleBarProps = {}) {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!showWindowControls) return
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
setIsMaximized(false)
})
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
setIsMaximized(maximized)
})
}, [showWindowControls])
return ( return (
<div className="title-bar"> <div className="title-bar">
<div className="title-brand"> <div className="title-brand">
@@ -25,6 +46,37 @@ function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBar
</button> </button>
) : null} ) : null}
</div> </div>
{showWindowControls ? (
<div className="title-window-controls">
<button
type="button"
className="title-window-control-btn"
aria-label="最小化"
title="最小化"
onClick={() => window.electronAPI.window.minimize()}
>
<Minus size={14} />
</button>
<button
type="button"
className="title-window-control-btn"
aria-label={isMaximized ? '还原' : '最大化'}
title={isMaximized ? '还原' : '最大化'}
onClick={() => window.electronAPI.window.maximize()}
>
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
</button>
<button
type="button"
className="title-window-control-btn is-close"
aria-label="关闭"
title="关闭"
onClick={() => window.electronAPI.window.close()}
>
<X size={14} />
</button>
</div>
) : null}
</div> </div>
) )
} }

View File

@@ -209,16 +209,7 @@ function AnnualReportPage() {
return ( return (
<div className="annual-report-page"> <div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} /> <Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p> <p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<div className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</div> </div>
) )
} }
@@ -264,30 +255,6 @@ function AnnualReportPage() {
<Sparkles size={32} className="header-icon" /> <Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-desc"></p> <p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections"> <div className="report-sections">
<section className="report-section"> <section className="report-section">
@@ -311,7 +278,6 @@ function AnnualReportPage() {
</div> </div>
))} ))}
</div> </div>
{renderYearLoadStatus()}
</div> </div>
<button <button
@@ -358,7 +324,6 @@ function AnnualReportPage() {
</div> </div>
))} ))}
</div> </div>
{renderYearLoadStatus()}
</div> </div>
<button <button

View File

@@ -3854,12 +3854,6 @@ function ChatPage(props: ChatPageProps) {
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}> <button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} /> <RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button> </button>
{isSessionListSyncing && (
<div className="session-sync-indicator">
<Loader2 size={12} className="spin" />
<span></span>
</div>
)}
</div> </div>
</div> </div>
{/* 折叠群 header */} {/* 折叠群 header */}

View File

@@ -891,28 +891,6 @@ function ContactsPage() {
</label> </label>
</div> </div>
<div className="contacts-count">
{filteredContacts.length} / {contacts.length}
{contactsUpdatedAt && (
<span className="contacts-cache-meta">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · {contactsUpdatedAtLabel}
</span>
)}
{contacts.length > 0 && (
<span className="contacts-cache-meta">
{avatarCachedCount}/{contacts.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{isLoading && contacts.length > 0 && (
<span className="contacts-cache-meta syncing">...</span>
)}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
{avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div>
{exportMode && ( {exportMode && (
<div className="selection-toolbar"> <div className="selection-toolbar">

View File

@@ -11,6 +11,33 @@
padding: 28px 32px; padding: 28px 32px;
background: rgba(15, 23, 42, 0.28); background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
animation: settingsFadeIn 0.2s ease;
&.closing {
animation: settingsFadeOut 0.2s ease forwards;
}
}
@keyframes settingsFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0);
}
to {
opacity: 1;
backdrop-filter: blur(10px);
}
}
@keyframes settingsFadeOut {
from {
opacity: 1;
backdrop-filter: blur(10px);
}
to {
opacity: 0;
backdrop-filter: blur(0);
}
} }
.settings-page { .settings-page {
@@ -25,6 +52,33 @@
border-radius: 24px; border-radius: 24px;
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22); box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
overflow: hidden; overflow: hidden;
animation: settingsSlideUp 0.3s ease;
&.closing {
animation: settingsSlideDown 0.2s ease forwards;
}
}
@keyframes settingsSlideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes settingsSlideDown {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
} }
.settings-header { .settings-header {

View File

@@ -134,6 +134,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false) const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false) const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false) const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({}) const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 state // 安全设置 state
@@ -203,7 +204,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (!onClose) return if (!onClose) return
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
onClose() handleClose()
} }
} }
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
@@ -445,6 +446,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setTimeout(() => setMessage(null), 3000) setTimeout(() => setMessage(null), 3000)
} }
const handleClose = () => {
if (!onClose) return
setIsClosing(true)
setTimeout(() => {
onClose()
}, 200)
}
type WxidKeys = { type WxidKeys = {
decryptKey: string decryptKey: string
imageXorKey: number | null imageXorKey: number | null
@@ -2076,8 +2085,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
return ( return (
<div className="settings-modal-overlay" onClick={() => onClose?.()}> <div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
<div className="settings-page" onClick={(event) => event.stopPropagation()}> <div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>} {message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */} {/* 多账号选择对话框 */}
@@ -2116,7 +2125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'} <Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button> </button>
{onClose && ( {onClose && (
<button type="button" className="settings-close-btn" onClick={onClose} aria-label="关闭设置"> <button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
<X size={18} /> <X size={18} />
</button> </button>
)} )}

View File

@@ -11,6 +11,8 @@ export interface ElectronAPI {
window: { window: {
minimize: () => void minimize: () => void
maximize: () => void maximize: () => void
isMaximized: () => Promise<boolean>
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
close: () => void close: () => void
openAgreementWindow: () => Promise<boolean> openAgreementWindow: () => Promise<boolean>
completeOnboarding: () => Promise<boolean> completeOnboarding: () => Promise<boolean>