diff --git a/docs/MAC-KEY-FAQ.md b/docs/MAC-KEY-FAQ.md new file mode 100644 index 0000000..c91117d --- /dev/null +++ b/docs/MAC-KEY-FAQ.md @@ -0,0 +1,53 @@ +# macOS 微信密钥自动获取失败排障指南 + +如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。 + +### 请立刻停止连续重试 + +当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取: + +- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found +- HOOK_FAILED 或 Native Hook Failed +- patch_breakpoint_failed +- thread_get_state_failed + +现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。 + +### 可能的尝试流程 + +根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题: + +1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。 +2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。 +3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。 +4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。 +5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。 +6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。 + +### 常见报错与应对方法 + +为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策: + +**SCAN_FAILED: No suitable module found** +这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。 + +**SCAN_FAILED: Sink pattern not found** +这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。 + +**patch_breakpoint_failed 或 thread_get_state_failed** +这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。 + +**task_for_pid:5** +这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。 + +### 关于推荐版本的补充说明 + +截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。 + +这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。 + +### 最后的几点建议 + +首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。 + +最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。 \ No newline at end of file diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 53fefa4..7d4e4d0 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -24,6 +24,9 @@ export class KeyServiceMac { private machVmReadOverwrite: any = null private machPortDeallocate: any = null private _needsElevation = false + private restrictedFailureCount = 0 + private restrictedFailureAt = 0 + private readonly restrictedFailureWindowMs = 8 * 60_000 private getHelperPath(): string { const isPackaged = app.isPackaged @@ -186,18 +189,25 @@ export class KeyServiceMac { } if (!parsed.success) { - const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + const errorMsg = this.enrichDbKeyErrorMessage( + this.mapDbKeyErrorMessage(parsed.code, parsed.detail), + parsed.code, + parsed.detail + ) onStatus?.(errorMsg, 2) return { success: false, error: errorMsg } } + this.resetRestrictedFailureState() onStatus?.('密钥获取成功', 1) return { success: true, key: parsed.key } } catch (e: any) { console.error('[KeyServiceMac] Error:', e) console.error('[KeyServiceMac] Stack:', e.stack) - onStatus?.('获取失败: ' + e.message, 2) - return { success: false, error: e.message } + const rawError = `${e?.message || e || ''}`.trim() + const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError) + onStatus?.(resolvedError, 2) + return { success: false, error: resolvedError } } } @@ -223,6 +233,149 @@ export class KeyServiceMac { return this.parseDbKeyResult(helperResult) } + private resetRestrictedFailureState(): void { + this.restrictedFailureCount = 0 + this.restrictedFailureAt = 0 + } + + private markRestrictedFailureAndGetCount(): number { + const now = Date.now() + if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) { + this.restrictedFailureCount = 0 + } + this.restrictedFailureAt = now + this.restrictedFailureCount += 1 + return this.restrictedFailureCount + } + + private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean { + const normalizedCode = String(code || '').toUpperCase() + const normalizedDetail = String(detail || '').toLowerCase() + if (!normalizedCode && !normalizedDetail) return false + + if (normalizedCode === 'SCAN_FAILED') { + return normalizedDetail.includes('sink pattern not found') + || normalizedDetail.includes('no suitable module found') + } + + if (normalizedCode === 'HOOK_FAILED') { + return normalizedDetail.includes('patch_breakpoint_failed') + || normalizedDetail.includes('thread_get_state_failed') + || normalizedDetail.includes('native hook failed') + } + + if (normalizedCode === 'ATTACH_FAILED') { + return normalizedDetail.includes('task_for_pid:5') + || normalizedDetail.includes('thread_get_state_failed') + } + + return normalizedDetail.includes('patch_breakpoint_failed') + || normalizedDetail.includes('thread_get_state_failed') + || normalizedDetail.includes('sink pattern not found') + || normalizedDetail.includes('no suitable module found') + } + + private getMacRecoveryHint(isRepeatedFailure: boolean): string { + const steps = isRepeatedFailure + ? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。' + : '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。' + return `${steps}\n请不要连续重试,以免触发微信安全模式或系统内存保护。` + } + + private simplifyDbKeyDetail(detail?: string): string { + const raw = String(detail || '') + .replace(/^WF_OK::/i, '') + .replace(/^WF_ERR::/i, '') + .replace(/\r?\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!raw) return '' + + const keys = [ + 'No suitable module found', + 'Sink pattern not found', + 'patch_breakpoint_failed', + 'thread_get_state_failed', + 'task_for_pid:5', + 'attach_wait_timeout', + 'HOOK_TIMEOUT', + 'FRIDA_TIMEOUT' + ] + for (const key of keys) { + if (raw.includes(key)) return key + } + + const stripped = raw + .replace(/\[xkey_helper\]/gi, ' ') + .replace(/\[debug\]/gi, ' ') + .replace(/\[\*\]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!stripped) return '' + return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped + } + + private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } { + const raw = String(text || '') + if (!raw) return {} + + const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/) + if (explicit) { + return { + code: explicit[1] || 'UNKNOWN', + detail: this.simplifyDbKeyDetail(explicit[2] || '') + } + } + + if (raw.includes('No suitable module found')) { + return { code: 'SCAN_FAILED', detail: 'No suitable module found' } + } + if (raw.includes('Sink pattern not found')) { + return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' } + } + if (raw.includes('patch_breakpoint_failed')) { + return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' } + } + if (raw.includes('thread_get_state_failed')) { + return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' } + } + if (raw.includes('task_for_pid:5')) { + return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' } + } + + return {} + } + + private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string { + const text = String(rawError || '').trim() + const { code, detail } = this.extractDbKeyErrorFromAnyText(text) + if (code) { + const mapped = this.mapDbKeyErrorMessage(code, detail) + return this.enrichDbKeyErrorMessage(mapped, code, detail) + } + + if (text.includes('helper timeout')) { + return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。' + } + if (text.includes('helper returned empty output') || text.includes('invalid json')) { + return '获取失败:helper 未返回可识别结果,请彻底退出微信后重启电脑再试。' + } + if (text.includes('xkey_helper not found')) { + return '获取失败:未找到 xkey_helper,请重新安装 WeFlow 后重试。' + } + return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。' + } + + private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string { + if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage + + const failureCount = this.markRestrictedFailureAndGetCount() + if (failureCount >= 2) { + return `${baseMessage}\n检测到连续失败,疑似已进入受限状态。请先彻底退出微信并重启电脑,再按下方步骤处理。\n${this.getMacRecoveryHint(true)}` + } + return `${baseMessage}\n${this.getMacRecoveryHint(false)}` + } + private async getWeChatPid(): Promise { try { // 优先使用 pgrep -x 精确匹配进程名 @@ -498,7 +651,12 @@ export class KeyServiceMac { const errNum = parts[1] || 'unknown' const errMsg = parts[2] || 'unknown' const partial = parts.slice(3).join('::') - throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) + if (errNum === '-128' || String(errMsg).includes('User canceled')) { + throw new Error('User canceled') + } + const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`) + if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}` + throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`) } const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined @@ -520,49 +678,57 @@ export class KeyServiceMac { // 其次找 result 字段 const resultPayload = allJson.find(p => typeof p?.result === 'string') if (resultPayload) return resultPayload.result - throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1]) + const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput) + if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}` + throw new Error('elevated helper returned invalid output') } private mapDbKeyErrorMessage(code?: string, detail?: string): string { + const normalizedDetail = this.simplifyDbKeyDetail(detail) if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' if (code === 'ATTACH_FAILED') { const isDevElectron = process.execPath.includes('/node_modules/electron/') - if ((detail || '').includes('task_for_pid:5')) { + if (normalizedDetail.includes('task_for_pid:5')) { if (isDevElectron) { return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。` } - return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。' + return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。' } - return `无法附加到进程 (${detail || ''})` + if (normalizedDetail.includes('thread_get_state_failed')) { + return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。` + } + return `无法附加到进程 (${normalizedDetail || ''})` } if (code === 'FRIDA_FAILED') { - if ((detail || '').includes('FRIDA_TIMEOUT')) { + if (normalizedDetail.includes('FRIDA_TIMEOUT')) { return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。' } - return `Frida 语义定位失败 (${detail || ''})` + return `Frida 语义定位失败 (${normalizedDetail || ''})` } if (code === 'HOOK_FAILED') { - if ((detail || '').includes('HOOK_TIMEOUT')) { + if (normalizedDetail.includes('HOOK_TIMEOUT')) { return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。' } - if ((detail || '').includes('attach_wait_timeout')) { + if (normalizedDetail.includes('attach_wait_timeout')) { return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' } - return `原生 Hook 失败 (${detail || ''})` + if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) { + return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。` + } + return `原生 Hook 失败 (${normalizedDetail || ''})` } if (code === 'HOOK_TARGET_ONLY') { - return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` + return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` } if (code === 'SCAN_FAILED') { - const normalizedDetail = (detail || '').trim() if (!normalizedDetail) { return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。' } if (normalizedDetail.includes('Sink pattern not found')) { - return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。' + return '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。' } if (normalizedDetail.includes('No suitable module found')) { - return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。' + return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。' } return `内存扫描失败:${normalizedDetail}` } diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 81d1d6a..86d0c44 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -70,7 +70,7 @@ interface DualReportData { friendExclusivePhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } - response?: { avg: number; fastest: number; slowest: number; count: number } + response?: { avg: number; fastest: number; slowest?: number; count: number } monthly?: Record streak?: { days: number; startDate: string; endDate: string } } @@ -149,7 +149,7 @@ function DualReportWindow() { const generateReport = async (friendUsername: string, year: number) => { const taskId = registerBackgroundTask({ - sourcePage: 'dualReport', + sourcePage: 'annualReport', title: '双人报告生成', detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 双人年度报告`, progressText: '初始化', @@ -302,6 +302,17 @@ function DualReportWindow() { const handleClose = () => { navigate('/home') } const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + const formatMonthDayTime = (timestamp?: number) => { + if (!timestamp || Number.isNaN(timestamp)) return '' + const msTimestamp = timestamp > 1e12 ? timestamp : timestamp * 1000 + const date = new Date(msTimestamp) + if (Number.isNaN(date.getTime())) return '' + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${month}-${day} ${hour}:${minute}` + } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const waitForNextPaint = () => new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) }) @@ -427,7 +438,11 @@ function DualReportWindow() { // 计算第一句话数据 const displayFirstChat = reportData.yearFirstChat || reportData.firstChat - const firstChatArray = (reportData.yearFirstChatMessages || reportData.firstChatMessages || (displayFirstChat ? [displayFirstChat] : [])).slice(0, 3) + const firstChatArray = ( + reportData.yearFirstChat?.firstThreeMessages || + reportData.firstChatMessages || + (displayFirstChat ? [displayFirstChat] : []) + ).slice(0, 3) // 聊天火花 const showSpark = reportData.streak && reportData.streak.days > 0 @@ -487,8 +502,8 @@ function DualReportWindow() {

故事的开始

{firstChatArray.map((chat: any, idx: number) => ( -
- {chat.createTimeStr || formatMonthDayTime(chat.timestamp)} +
+ {chat.createTimeStr || formatMonthDayTime(chat.createTime)}
{formatFirstChat(chat.content)}
))} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e0e9f9b..e46649e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -65,6 +65,7 @@ import type { SnsPost } from '../types/sns' import { cloneExportDateRange, cloneExportDateRangeSelection, + createExportDateRangeSelectionFromPreset, createDateRangeByLastNDays, createDefaultDateRange, createDefaultExportDateRangeSelection, @@ -1599,6 +1600,19 @@ const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportD left.dateRange.end.getTime() === right.dateRange.end.getTime() ) +const resolveDynamicExportSelection = ( + selection: ExportDateRangeSelection, + now = new Date() +): ExportDateRangeSelection => { + if (selection.useAllTime) { + return cloneExportDateRangeSelection(selection) + } + if (selection.preset === 'custom') { + return cloneExportDateRangeSelection(selection) + } + return createExportDateRangeSelectionFromPreset(selection.preset, now) +} + const pickSessionMediaMetric = ( metricRaw: SessionExportMetric | SessionContentMetric | undefined ): SessionContentMetric | null => { @@ -4790,19 +4804,20 @@ function ExportPage() { const clearSelection = () => setSelectedSessions(new Set()) const openExportDialog = useCallback((payload: Omit & { intent?: ExportDialogState['intent'] }) => { + const dynamicDefaultRangeSelection = resolveDynamicExportSelection(exportDefaultDateRangeSelection, new Date()) setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload }) setIsTimeRangeDialogOpen(false) setTimeRangeBounds(null) - setTimeRangeSelection(exportDefaultDateRangeSelection) + setTimeRangeSelection(dynamicDefaultRangeSelection) setOptions(prev => { - const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange) + const nextDateRange = cloneExportDateRange(dynamicDefaultRangeSelection.dateRange) const next: ExportOptions = { ...prev, format: exportDefaultFormat, exportAvatars: exportDefaultAvatars, - useAllTime: exportDefaultDateRangeSelection.useAllTime, + useAllTime: dynamicDefaultRangeSelection.useAllTime, dateRange: nextDateRange, exportMedia: Boolean( exportDefaultMedia.images || @@ -4863,9 +4878,13 @@ function ExportPage() { setTimeRangeBounds(null) }, []) - const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise => { + const resolveChatExportTimeRangeBounds = useCallback(async ( + sessionIds: string[], + options?: { forceRefresh?: boolean } + ): Promise => { const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean))) if (normalizedSessionIds.length === 0) return null + const forceRefresh = options?.forceRefresh === true const sessionRowMap = new Map() for (const session of sessions) { @@ -4928,29 +4947,36 @@ function ExportPage() { return !resolved?.hasMin || !resolved?.hasMax }) - const staleSessionIds = new Set() - - if (missingSessionIds().length > 0) { - const cacheResult = await window.electronAPI.chat.getExportSessionStats( - missingSessionIds(), - { includeRelations: false, allowStaleCache: true, cacheOnly: true } - ) - applyStatsResult(cacheResult) - for (const sessionId of cacheResult?.needsRefresh || []) { - staleSessionIds.add(String(sessionId || '').trim()) - } - } - - const sessionsNeedingFreshStats = Array.from(new Set([ - ...missingSessionIds(), - ...Array.from(staleSessionIds).filter(Boolean) - ])) - - if (sessionsNeedingFreshStats.length > 0) { + if (forceRefresh) { applyStatsResult(await window.electronAPI.chat.getExportSessionStats( - sessionsNeedingFreshStats, - { includeRelations: false } + normalizedSessionIds, + { includeRelations: false, forceRefresh: true } )) + } else { + const staleSessionIds = new Set() + + if (missingSessionIds().length > 0) { + const cacheResult = await window.electronAPI.chat.getExportSessionStats( + missingSessionIds(), + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + applyStatsResult(cacheResult) + for (const sessionId of cacheResult?.needsRefresh || []) { + staleSessionIds.add(String(sessionId || '').trim()) + } + } + + const sessionsNeedingFreshStats = Array.from(new Set([ + ...missingSessionIds(), + ...Array.from(staleSessionIds).filter(Boolean) + ])) + + if (sessionsNeedingFreshStats.length > 0) { + applyStatsResult(await window.electronAPI.chat.getExportSessionStats( + sessionsNeedingFreshStats, + { includeRelations: false } + )) + } } if (missingSessionIds().length > 0) { @@ -4971,14 +4997,26 @@ function ExportPage() { if (isResolvingTimeRangeBounds) return setIsResolvingTimeRangeBounds(true) try { + const liveSelection = resolveDynamicExportSelection(timeRangeSelection, new Date()) + if (!areExportSelectionsEqual(liveSelection, timeRangeSelection)) { + setTimeRangeSelection(liveSelection) + setOptions(prev => ({ + ...prev, + useAllTime: liveSelection.useAllTime, + dateRange: cloneExportDateRange(liveSelection.dateRange) + })) + } + let nextBounds: TimeRangeBounds | null = null if (exportDialog.scope !== 'sns') { - nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds) + nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds, { + forceRefresh: exportDialog.scope === 'single' + }) } setTimeRangeBounds(nextBounds) if (nextBounds) { - const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds) - if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) { + const nextSelection = clampExportSelectionToBounds(liveSelection, nextBounds) + if (!areExportSelectionsEqual(nextSelection, liveSelection)) { setTimeRangeSelection(nextSelection) setOptions(prev => ({ ...prev, @@ -5056,47 +5094,51 @@ function ExportPage() { return unsubscribe }, [loadBaseConfig, openExportDialog]) - const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { + const buildExportOptions = ( + scope: TaskScope, + contentType?: ContentType, + sourceOptions: ExportOptions = options + ): ElectronExportOptions => { const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' const exportMediaEnabled = Boolean( - options.exportImages || - options.exportVoices || - options.exportVideos || - options.exportEmojis || - options.exportFiles + sourceOptions.exportImages || + sourceOptions.exportVoices || + sourceOptions.exportVideos || + sourceOptions.exportEmojis || + sourceOptions.exportFiles ) const base: ElectronExportOptions = { - format: options.format, - exportAvatars: options.exportAvatars, + format: sourceOptions.format, + exportAvatars: sourceOptions.exportAvatars, exportMedia: exportMediaEnabled, - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - excelCompactColumns: options.excelCompactColumns, - txtColumns: options.txtColumns, - displayNamePreference: options.displayNamePreference, - exportConcurrency: options.exportConcurrency, + exportImages: sourceOptions.exportImages, + exportVoices: sourceOptions.exportVoices, + exportVideos: sourceOptions.exportVideos, + exportEmojis: sourceOptions.exportEmojis, + exportFiles: sourceOptions.exportFiles, + maxFileSizeMb: sourceOptions.maxFileSizeMb, + exportVoiceAsText: sourceOptions.exportVoiceAsText, + excelCompactColumns: sourceOptions.excelCompactColumns, + txtColumns: sourceOptions.txtColumns, + displayNamePreference: sourceOptions.displayNamePreference, + exportConcurrency: sourceOptions.exportConcurrency, fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, - dateRange: options.useAllTime + dateRange: sourceOptions.useAllTime ? null - : options.dateRange + : sourceOptions.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - end: Math.floor(options.dateRange.end.getTime() / 1000) + start: Math.floor(sourceOptions.dateRange.start.getTime() / 1000), + end: Math.floor(sourceOptions.dateRange.end.getTime() / 1000) } : null } if (scope === 'content' && contentType) { if (contentType === 'text') { - const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency)) + const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? sourceOptions.exportConcurrency)) return { ...base, contentType, @@ -5127,14 +5169,14 @@ function ExportPage() { return base } - const buildSnsExportOptions = () => { + const buildSnsExportOptions = (sourceOptions: ExportOptions = options) => { const format: SnsTimelineExportFormat = snsExportFormat - const dateRange = options.useAllTime + const dateRange = sourceOptions.useAllTime ? null - : options.dateRange + : sourceOptions.dateRange ? { - startTime: Math.floor(options.dateRange.start.getTime() / 1000), - endTime: Math.floor(options.dateRange.end.getTime() / 1000) + startTime: Math.floor(sourceOptions.dateRange.start.getTime() / 1000), + endTime: Math.floor(sourceOptions.dateRange.end.getTime() / 1000) } : null @@ -5946,12 +5988,27 @@ function ExportPage() { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return + const effectiveRangeSelection = resolveDynamicExportSelection(timeRangeSelection, new Date()) + if (!areExportSelectionsEqual(effectiveRangeSelection, timeRangeSelection)) { + setTimeRangeSelection(effectiveRangeSelection) + } + const effectiveOptionsState: ExportOptions = { + ...options, + useAllTime: effectiveRangeSelection.useAllTime, + dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange) + } + setOptions(prev => ({ + ...prev, + useAllTime: effectiveOptionsState.useAllTime, + dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange) + })) + const isAutomationCreateIntent = exportDialog.intent === 'automation-create' const exportOptions = exportDialog.scope === 'sns' ? undefined - : buildExportOptions(exportDialog.scope, exportDialog.contentType) + : buildExportOptions(exportDialog.scope, exportDialog.contentType, effectiveOptionsState) const snsOptions = exportDialog.scope === 'sns' - ? buildSnsExportOptions() + ? buildSnsExportOptions(effectiveOptionsState) : undefined const title = exportDialog.scope === 'single' @@ -5968,7 +6025,7 @@ function ExportPage() { return } const { dateRange: _discard, ...optionTemplate } = exportOptions - const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection) + const normalizedRangeSelection = cloneExportDateRangeSelection(effectiveRangeSelection) const scope = exportDialog.scope === 'single' ? 'single' : exportDialog.scope === 'content' diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index a94c532..76188e1 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -338,6 +338,22 @@ color: var(--text-secondary); } + .mac-key-faq-link { + border: none; + background: transparent; + color: #0f62fe; + text-decoration: underline; + cursor: pointer; + font-size: 12px; + padding: 0; + margin-top: 6px; + display: inline-block; + + &:hover { + opacity: 0.9; + } + } + .manual-prompt { background: rgba(139, 115, 85, 0.1); border: 1px dashed rgba(139, 115, 85, 0.3); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c65902d..80a21e6 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -56,6 +56,7 @@ const aiTabs: Array<{ id: Extract(null) const [showDecryptKey, setShowDecryptKey] = useState(false) const [dbKeyStatus, setDbKeyStatus] = useState('') + const [dbKeyError, setDbKeyError] = useState('') const [imageKeyStatus, setImageKeyStatus] = useState('') const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false) @@ -1254,12 +1256,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (isFetchingDbKey) return setIsFetchingDbKey(true) setIsManualStartPrompt(false) + setDbKeyError('') setDbKeyStatus('正在连接微信进程...') try { const result = await window.electronAPI.key.autoGetDbKey() if (result.success && result.key) { setDecryptKey(result.key) setDbKeyStatus('密钥获取成功') + setDbKeyError('') showMessage('已自动获取解密密钥', true) await syncCurrentKeys({ decryptKey: result.key, wxid }) const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) @@ -1274,17 +1278,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') + setDbKeyError('') } else { - showMessage(result.error || '自动获取密钥失败', false) + const failureMessage = result.error || '自动获取密钥失败' + setDbKeyError(failureMessage) + showMessage(failureMessage, false) } } } catch (e: any) { - showMessage(`自动获取密钥失败: ${e}`, false) + const failureMessage = `自动获取密钥失败: ${e}` + setDbKeyError(failureMessage) + showMessage(failureMessage, false) } finally { setIsFetchingDbKey(false) } } + const openMacKeyFaq = () => { + void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL) + } + const handleManualConfirm = async () => { setIsManualStartPrompt(false) handleAutoGetDbKey() @@ -2207,6 +2220,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { )} {dbKeyStatus &&
{dbKeyStatus}
} + {isMac && dbKeyError && ( + + )}
diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index 49a77e6..4f6a80a 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -666,7 +666,28 @@ font-size: 14px; margin-top: 16px; display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 8px; + white-space: pre-wrap; +} + +.error-text { + width: 100%; +} + +.error-link-btn { + border: none; + background: transparent; + color: #0f62fe; + text-decoration: underline; + cursor: pointer; + font-size: 13px; + padding: 0; + + &:hover { + opacity: 0.9; + } } .intro-footer { diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 7234964..c422894 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -14,6 +14,7 @@ import './WelcomePage.scss' const isMac = navigator.userAgent.toLowerCase().includes('mac') const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux +const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试' const dbPathPlaceholder = isMac @@ -39,10 +40,19 @@ interface WelcomePageProps { const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => { const base = String(error || '自动获取密钥失败').trim() + const isInternalLine = (line: string): boolean => { + const lower = line.toLowerCase() + return lower.includes('xkey_helper') + || lower.includes('[debug]') + || lower.includes('breakpoint') + || lower.includes('hook installed @') + || lower.includes('scanner ') + } const tailLogs = Array.isArray(logs) ? logs .map(item => String(item || '').trim()) - .filter(Boolean) + .filter(item => Boolean(item) && !isInternalLine(item)) + .map(item => item.length > 80 ? `${item.slice(0, 80)}...` : item) .slice(-6) : [] if (tailLogs.length === 0) return base @@ -117,6 +127,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false) const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode) const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false) + const [lastDbKeyError, setLastDbKeyError] = useState('') const imagePrefetchAttemptRef = useRef('') // 安全相关 state @@ -476,6 +487,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setShowDbKeyConfirm(false) setIsFetchingDbKey(true) setError('') + setLastDbKeyError('') setIsManualStartPrompt(false) setDbKeyStatus('正在连接微信进程...') try { @@ -499,20 +511,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { ) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') + setLastDbKeyError('') } else { if (result.error?.includes('尚未完成登录')) { setDbKeyStatus('请先在微信完成登录后重试') } - setError(formatDbKeyFailureMessage(result.error, result.logs)) + const failureMessage = formatDbKeyFailureMessage(result.error, result.logs) + setError(failureMessage) + setLastDbKeyError(failureMessage) } } } catch (e) { - setError(`自动获取密钥失败: ${e}`) + const failureMessage = `自动获取密钥失败: ${e}` + setError(failureMessage) + setLastDbKeyError(failureMessage) } finally { setIsFetchingDbKey(false) } } + const openMacKeyFaq = () => { + void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL) + } + const handleManualConfirm = async () => { setIsManualStartPrompt(false) handleAutoGetDbKey() @@ -1161,7 +1182,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { )}
- {error &&
{error}
} + {error && ( +
+
{error}
+ {isMac && error === lastDbKeyError && ( + + )} +
+ )} {currentStep.id === 'intro' && (