mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
fix: 修复媒体导出与 HTML 聊天档案体验
- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖 - 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题 - 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移 - 修复 HTML 语音播放器超出消息气泡的问题 - 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿 - 移除图片解密页面中手动配置 FFmpeg 的相关提示 - 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
This commit is contained in:
@@ -147,6 +147,7 @@ body {
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.bubble {
|
||||
min-width: 0;
|
||||
max-width: min(78%, 760px);
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -157,9 +158,8 @@ body {
|
||||
.sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; }
|
||||
.sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; }
|
||||
.content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; }
|
||||
.audio-wrap, .audio { width: 260px; }
|
||||
.audio-wrap { min-width: 260px; }
|
||||
.audio { display: block; height: 38px; }
|
||||
.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }
|
||||
.audio { display: block; width: 100%; max-width: 100%; height: 38px; }
|
||||
.media-status {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
@@ -263,7 +263,7 @@ body {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.bubble { max-width: calc(100vw - 92px); }
|
||||
.audio-wrap, .audio { width: min(260px, calc(100vw - 130px)); min-width: 0; }
|
||||
.audio-wrap { width: min(260px, calc(100vw - 130px)); }
|
||||
}
|
||||
`
|
||||
|
||||
@@ -296,9 +296,19 @@ const renderExportScript = (name: string): string => `
|
||||
let filtered = []
|
||||
let windowStart = 0
|
||||
let windowEnd = 0
|
||||
let loading = false
|
||||
let scrollLoadPending = false
|
||||
let scrollLoadSuppressed = false
|
||||
let lastScrollTop = 0
|
||||
let zoom = 1
|
||||
|
||||
const nextFrame = (callback) => {
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(callback)
|
||||
} else {
|
||||
window.setTimeout(callback, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (value) => String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character
|
||||
@@ -423,6 +433,18 @@ const renderExportScript = (name: string): string => `
|
||||
const shown = Math.max(0, windowEnd - windowStart)
|
||||
count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length
|
||||
}
|
||||
const setScrollTop = (value) => {
|
||||
scrollLoadSuppressed = true
|
||||
const previousBehavior = list.style.scrollBehavior
|
||||
list.style.scrollBehavior = 'auto'
|
||||
list.scrollTop = value
|
||||
lastScrollTop = list.scrollTop
|
||||
nextFrame(() => {
|
||||
list.style.scrollBehavior = previousBehavior
|
||||
lastScrollTop = list.scrollTop
|
||||
scrollLoadSuppressed = false
|
||||
})
|
||||
}
|
||||
const renderWindow = (anchorIndex, anchorOffset) => {
|
||||
const visible = filtered.slice(windowStart, windowEnd)
|
||||
const before = windowStart > 0 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
|
||||
@@ -432,7 +454,7 @@ const renderExportScript = (name: string): string => `
|
||||
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
|
||||
if (Number.isInteger(anchorIndex)) {
|
||||
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
|
||||
if (anchor) list.scrollTop = anchor.offsetTop - anchorOffset
|
||||
if (anchor) setScrollTop(anchor.offsetTop - anchorOffset)
|
||||
}
|
||||
updateCount()
|
||||
updateActiveMonth()
|
||||
@@ -445,7 +467,7 @@ const renderExportScript = (name: string): string => `
|
||||
windowEnd = Math.min(filtered.length, PAGE_SIZE)
|
||||
}
|
||||
renderWindow()
|
||||
list.scrollTop = preferLatest ? list.scrollHeight : 0
|
||||
setScrollTop(preferLatest ? list.scrollHeight : 0)
|
||||
}
|
||||
const applyFilters = () => {
|
||||
const term = query.value.trim().toLowerCase()
|
||||
@@ -464,7 +486,7 @@ const renderExportScript = (name: string): string => `
|
||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||
renderWindow()
|
||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||
list.scrollTop = target ? Math.max(0, target.offsetTop - 24) : 0
|
||||
setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0)
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
})
|
||||
@@ -485,18 +507,31 @@ const renderExportScript = (name: string): string => `
|
||||
renderWindow(anchorIndex, anchorOffset)
|
||||
}
|
||||
|
||||
const scheduleWindowSlide = (direction) => {
|
||||
if (scrollLoadPending) return
|
||||
scrollLoadPending = true
|
||||
nextFrame(() => {
|
||||
if (direction < 0 ? windowStart > 0 : windowEnd < filtered.length) {
|
||||
slideWindow(direction)
|
||||
}
|
||||
scrollLoadPending = false
|
||||
})
|
||||
}
|
||||
list.addEventListener('scroll', () => {
|
||||
if (loading) return
|
||||
if (list.scrollTop < 180 && windowStart > 0) {
|
||||
loading = true
|
||||
slideWindow(-1)
|
||||
loading = false
|
||||
} else if (list.scrollHeight - list.scrollTop - list.clientHeight < 240 && windowEnd < filtered.length) {
|
||||
loading = true
|
||||
slideWindow(1)
|
||||
loading = false
|
||||
}
|
||||
const currentTop = list.scrollTop
|
||||
const movingUp = currentTop < lastScrollTop
|
||||
const nearTop = currentTop < 180
|
||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||
lastScrollTop = currentTop
|
||||
if (scrollLoadSuppressed) return
|
||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||
else if (!movingUp && nearBottom) scheduleWindowSlide(1)
|
||||
})
|
||||
list.addEventListener('wheel', (event) => {
|
||||
if (!scrollLoadSuppressed && event.deltaY < 0 && list.scrollTop <= 1) {
|
||||
scheduleWindowSlide(-1)
|
||||
}
|
||||
}, { passive: true })
|
||||
query.addEventListener('input', applyFilters)
|
||||
filters.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-kind]')
|
||||
|
||||
@@ -19,6 +19,7 @@ import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
import { FileAssetService } from './file-asset-service'
|
||||
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
@@ -104,6 +105,26 @@ export function mergeHtmlArchiveMessages(
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeHtmlArchiveSelfNames(
|
||||
messages: Message[],
|
||||
selfInfo: { wxid: string; nickname: string } | null
|
||||
): Message[] {
|
||||
const wxid = String(selfInfo?.wxid || '').trim()
|
||||
const nickname = String(selfInfo?.nickname || '').trim()
|
||||
if (!nickname || nickname === wxid || /^wxid_/i.test(nickname)) return messages
|
||||
return messages.map((message) => {
|
||||
if (!message.isSender) return message
|
||||
const currentName = String(message.name || '').trim()
|
||||
const senderId = String(message.senderId || '').trim()
|
||||
const usesRawAccount =
|
||||
!currentName ||
|
||||
currentName === wxid ||
|
||||
(senderId === wxid && currentName === senderId) ||
|
||||
/^wxid_/i.test(currentName)
|
||||
return usesRawAccount ? { ...message, name: nickname } : message
|
||||
})
|
||||
}
|
||||
|
||||
export async function readHtmlArchive(
|
||||
outputDir: string,
|
||||
sourceId: string,
|
||||
@@ -282,6 +303,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
const messages = (
|
||||
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime)
|
||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||
const name = String(value || '').trim()
|
||||
if (!name) return false
|
||||
if (name === selfInfo?.wxid) return false
|
||||
if (/^wxid_/i.test(name)) return false
|
||||
return true
|
||||
}
|
||||
for (const message of messages) {
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
@@ -290,7 +320,11 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
|
||||
message.name = mappedName
|
||||
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
||||
message.name = selfInfo.nickname
|
||||
}
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
|
||||
@@ -431,21 +465,31 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
let decryptedImage: { data: string; filePath: string } | null = null
|
||||
let usedFallback = false
|
||||
for (const attempt of getImageExportAttempts(request)) {
|
||||
const file = imageService.findImageFile(
|
||||
const file = await imageService.findImageFileAsync(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId
|
||||
sessionId: message.sessionId,
|
||||
sessionMd5: request.userMd5,
|
||||
createTime: message.createTime
|
||||
}
|
||||
)
|
||||
if (!file) continue
|
||||
fileFound = true
|
||||
const decrypted = imageService.decryptImageToBase64WithFallback(
|
||||
let decrypted = await imageService.decryptImageToBase64WithFallbackAsync(
|
||||
file,
|
||||
attempt.allowThumbnail
|
||||
)
|
||||
// Worker 启动异常时,普通 JPEG/PNG 仍可由主进程同步解析;
|
||||
// WXGF/HEVC 原图则以 Worker 的 FFmpeg 结果为准。
|
||||
if (!decrypted) {
|
||||
decrypted = imageService.decryptImageToBase64WithFallback(
|
||||
file,
|
||||
attempt.allowThumbnail
|
||||
)
|
||||
}
|
||||
if (!decrypted) continue
|
||||
decryptedImage = decrypted
|
||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
||||
@@ -536,12 +580,17 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
}
|
||||
const mergedMessages = mergeHtmlArchiveMessages(
|
||||
previousArchive.messages,
|
||||
messages,
|
||||
request.userMd5
|
||||
)
|
||||
const archive: HtmlExportArchive = {
|
||||
version: 1,
|
||||
sourceId: request.userMd5,
|
||||
name: request.name,
|
||||
exportedAt: new Date().toISOString(),
|
||||
messages: mergeHtmlArchiveMessages(previousArchive.messages, messages, request.userMd5)
|
||||
messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||
}
|
||||
await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8')
|
||||
await writeHtmlArchive(outputDir, archive)
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from 'os'
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { Worker } from 'worker_threads'
|
||||
import ffmpegStaticPath from 'ffmpeg-static'
|
||||
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
||||
import { loadSettings } from './services/settings-store'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
@@ -48,6 +49,8 @@ type ImageFindOptions = {
|
||||
accountDir?: string
|
||||
preferThumbnail?: boolean
|
||||
sessionId?: string
|
||||
sessionMd5?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
@@ -72,9 +75,17 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
|
||||
const selected = String(selectedPath || '').trim()
|
||||
const environment = String(process.env['FFMPEG_BIN'] || '').trim()
|
||||
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const staticExecutable = String(ffmpegStaticPath || '')
|
||||
.replace('app.asar', 'app.asar.unpacked')
|
||||
.trim()
|
||||
const candidates: FfmpegCandidate[] = [
|
||||
...(selected ? [{ executable: selected, source: 'selected' as const }] : []),
|
||||
...(environment ? [{ executable: environment, source: 'environment' as const }] : []),
|
||||
...(staticExecutable ? [{ executable: staticExecutable, source: 'bundled' as const }] : []),
|
||||
{
|
||||
executable: join(process.resourcesPath, 'resources', 'ffmpeg', executable),
|
||||
source: 'bundled'
|
||||
},
|
||||
{
|
||||
executable: join(process.resourcesPath, 'ffmpeg', executable),
|
||||
source: 'bundled'
|
||||
@@ -83,6 +94,12 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
|
||||
executable: join(process.cwd(), 'resources', 'ffmpeg', executable),
|
||||
source: 'bundled'
|
||||
},
|
||||
...(process.platform === 'darwin'
|
||||
? [
|
||||
{ executable: `/opt/homebrew/bin/${executable}`, source: 'system' as const },
|
||||
{ executable: `/usr/local/bin/${executable}`, source: 'system' as const }
|
||||
]
|
||||
: []),
|
||||
{ executable: process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', source: 'system' }
|
||||
]
|
||||
|
||||
@@ -96,10 +113,12 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
|
||||
|
||||
function resolveFfmpegExecutable(): string {
|
||||
for (const candidate of getFfmpegCandidates()) {
|
||||
if (candidate.source === 'environment' || candidate.source === 'system') {
|
||||
return candidate.executable
|
||||
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
|
||||
if (pathLike) {
|
||||
if (existsSync(candidate.executable)) return candidate.executable
|
||||
continue
|
||||
}
|
||||
if (existsSync(candidate.executable)) return candidate.executable
|
||||
return candidate.executable
|
||||
}
|
||||
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
}
|
||||
@@ -131,8 +150,7 @@ export async function inspectImageDecoderExecutable(
|
||||
const decoders = await runImageDecoderCommand(executable, ['-hide_banner', '-decoders'])
|
||||
return {
|
||||
installed: true,
|
||||
supportsHevc:
|
||||
decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output)
|
||||
supportsHevc: decoders.success && /^\s*[A-Z.]{6}\s+hevc\s/im.test(decoders.output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +205,7 @@ function normalizeDatBase(value) {
|
||||
|
||||
function isThumbnailName(fileName) {
|
||||
const lower = fileName.toLowerCase()
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
||||
}
|
||||
|
||||
function buildPreferredDatNames(baseName) {
|
||||
@@ -263,26 +281,63 @@ function unwrapWxgf(buffer, ffmpegPath) {
|
||||
}
|
||||
}
|
||||
|
||||
let hevcOffset = -1
|
||||
for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) {
|
||||
if (
|
||||
buffer[index] === 0x00 &&
|
||||
buffer[index + 1] === 0x00 &&
|
||||
buffer[index + 2] === 0x00 &&
|
||||
buffer[index + 3] === 0x01
|
||||
) {
|
||||
hevcOffset = index
|
||||
break
|
||||
function findHevcPartitions(data) {
|
||||
if (data.length < 15) return []
|
||||
const headerLength = data[4]
|
||||
if (headerLength < 5 || headerLength >= data.length) return []
|
||||
|
||||
for (const pattern of [Buffer.from([0, 0, 0, 1]), Buffer.from([0, 0, 1])]) {
|
||||
const partitions = []
|
||||
let searchOffset = headerLength
|
||||
while (searchOffset < data.length) {
|
||||
const relativeIndex = data.subarray(searchOffset).indexOf(pattern)
|
||||
if (relativeIndex < 0) break
|
||||
const offset = searchOffset + relativeIndex
|
||||
if (offset < 4) {
|
||||
searchOffset = offset + 1
|
||||
continue
|
||||
}
|
||||
const size = data.readUInt32BE(offset - 4)
|
||||
if (size === 0 || offset + size > data.length) {
|
||||
searchOffset = offset + 1
|
||||
continue
|
||||
}
|
||||
partitions.push({ offset, size })
|
||||
searchOffset = offset + size
|
||||
}
|
||||
if (partitions.length > 0) return partitions
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const partitions = findHevcPartitions(buffer)
|
||||
let hevcData = null
|
||||
if (partitions.length > 0) {
|
||||
const largest = partitions.reduce((best, current) =>
|
||||
current.size > best.size ? current : best
|
||||
)
|
||||
hevcData = buffer.subarray(largest.offset, largest.offset + largest.size)
|
||||
} else {
|
||||
for (let index = 4; index < Math.min(buffer.length - 4, 4096); index += 1) {
|
||||
if (
|
||||
buffer[index] === 0x00 &&
|
||||
buffer[index + 1] === 0x00 &&
|
||||
buffer[index + 2] === 0x00 &&
|
||||
buffer[index + 3] === 0x01
|
||||
) {
|
||||
hevcData = buffer.subarray(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hevcOffset < 0 || !ffmpegPath) return buffer
|
||||
if (!hevcData || !ffmpegPath) return buffer
|
||||
|
||||
const nonce = process.pid + '-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex')
|
||||
const tempBase = path.join(os.tmpdir(), 'wxe-wxgf-' + nonce)
|
||||
const inputPath = tempBase + '.hevc'
|
||||
const outputPath = tempBase + '.png'
|
||||
try {
|
||||
fs.writeFileSync(inputPath, buffer.subarray(hevcOffset))
|
||||
fs.writeFileSync(inputPath, hevcData)
|
||||
childProcess.execFileSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
@@ -501,22 +556,19 @@ export class ImageDecryptService {
|
||||
/**
|
||||
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
||||
*/
|
||||
findImageFile(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: ImageFindOptions
|
||||
): string | null {
|
||||
findImageFile(md5?: string, imageDatName?: string, options?: ImageFindOptions): string | null {
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
const sessionDirectory = this.getSessionDirectoryName(options?.sessionId)
|
||||
const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId)
|
||||
const pathCacheKey = [
|
||||
normalizedMd5,
|
||||
normalizedDatName,
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || '',
|
||||
sessionDirectory
|
||||
sessionDirectory,
|
||||
options?.createTime || 0
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
@@ -537,7 +589,8 @@ export class ImageDecryptService {
|
||||
imageDatName: normalizedDatName,
|
||||
accountDir,
|
||||
allowThumbnail,
|
||||
sessionDirectory
|
||||
sessionDirectory,
|
||||
createTime: options?.createTime
|
||||
})
|
||||
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
@@ -545,6 +598,16 @@ export class ImageDecryptService {
|
||||
// identifies the original image rather than the local file.
|
||||
const searchKeys = this.uniq([normalizedDatName, normalizedMd5])
|
||||
|
||||
if (sessionDirectory && allowThumbnail && options?.preferThumbnail) {
|
||||
const bubblePreview = this.findBubblePreview(
|
||||
accountDir,
|
||||
searchKeys,
|
||||
sessionDirectory,
|
||||
options?.createTime
|
||||
)
|
||||
if (bubblePreview) return rememberPath(bubblePreview)
|
||||
}
|
||||
|
||||
// Message rows already identify their conversation. Prefer that small,
|
||||
// deterministic directory before consulting the native hardlink database.
|
||||
if (sessionDirectory && existsSync(attachDir)) {
|
||||
@@ -554,7 +617,8 @@ export class ImageDecryptService {
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail,
|
||||
sessionDirectory
|
||||
sessionDirectory,
|
||||
options?.createTime
|
||||
)
|
||||
if (scopedHit) return rememberPath(scopedHit)
|
||||
}
|
||||
@@ -834,7 +898,7 @@ export class ImageDecryptService {
|
||||
imageDatName?: string,
|
||||
options?: ImageFindOptions
|
||||
): Promise<string | null> {
|
||||
const sessionDirectory = this.getSessionDirectoryName(options?.sessionId)
|
||||
const sessionDirectory = this.getSessionDirectoryName(options?.sessionMd5 || options?.sessionId)
|
||||
if (!sessionDirectory) return this.findImageFile(md5, imageDatName, options)
|
||||
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
@@ -846,7 +910,8 @@ export class ImageDecryptService {
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || '',
|
||||
sessionDirectory
|
||||
sessionDirectory,
|
||||
options?.createTime || 0
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
@@ -862,15 +927,28 @@ export class ImageDecryptService {
|
||||
if (!accountDir) return null
|
||||
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (normalizedDatName && existsSync(attachDir)) {
|
||||
const scopedHit = await this.findImageInSessionDirectoryAsync(
|
||||
attachDir,
|
||||
normalizedDatName,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail,
|
||||
sessionDirectory
|
||||
const searchKeys = this.uniq([normalizedDatName, normalizedMd5])
|
||||
if (allowThumbnail && options?.preferThumbnail) {
|
||||
const bubblePreview = await this.findBubblePreviewAsync(
|
||||
accountDir,
|
||||
searchKeys,
|
||||
sessionDirectory,
|
||||
options?.createTime
|
||||
)
|
||||
if (scopedHit) return rememberPath(scopedHit)
|
||||
if (bubblePreview) return rememberPath(bubblePreview)
|
||||
}
|
||||
if (existsSync(attachDir)) {
|
||||
for (const key of searchKeys) {
|
||||
const scopedHit = await this.findImageInSessionDirectoryAsync(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail,
|
||||
sessionDirectory,
|
||||
options?.createTime
|
||||
)
|
||||
if (scopedHit) return rememberPath(scopedHit)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
|
||||
@@ -895,7 +973,8 @@ export class ImageDecryptService {
|
||||
datName: string,
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail: boolean | undefined,
|
||||
sessionDirectory: string
|
||||
sessionDirectory: string,
|
||||
createTime?: number
|
||||
): Promise<string | null> {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized || !sessionDirectory) return null
|
||||
@@ -911,6 +990,7 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
monthDirectories = this.prioritizeImageMonth(monthDirectories, createTime)
|
||||
const variants = this.buildPreferredDatNames(normalized)
|
||||
for (const month of monthDirectories) {
|
||||
const candidates = ['Img', 'Image', 'image'].flatMap((subDirectory) =>
|
||||
@@ -926,10 +1006,7 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private isValidPersistentImageMeta(
|
||||
metadata: PersistentImageMeta,
|
||||
cacheKey: string
|
||||
): boolean {
|
||||
private isValidPersistentImageMeta(metadata: PersistentImageMeta, cacheKey: string): boolean {
|
||||
return (
|
||||
metadata !== null &&
|
||||
typeof metadata === 'object' &&
|
||||
@@ -1040,9 +1117,7 @@ export class ImageDecryptService {
|
||||
const entries = await fsPromises.readdir(cacheDir, { withFileTypes: true })
|
||||
const payloadNames = entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^[a-f0-9]{64}\.(?:jpe?g|png|gif|bmp|webp)$/i.test(entry.name)
|
||||
(entry) => entry.isFile() && /^[a-f0-9]{64}\.(?:jpe?g|png|gif|bmp|webp)$/i.test(entry.name)
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
const payloads = (
|
||||
@@ -1079,9 +1154,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const remainingPayloadKeys = new Set(
|
||||
payloads
|
||||
.slice(payloads.length - totalFiles)
|
||||
.map((payload) => payload.name.slice(0, 64))
|
||||
payloads.slice(payloads.length - totalFiles).map((payload) => payload.name.slice(0, 64))
|
||||
)
|
||||
const staleMetadata = entries.filter(
|
||||
(entry) =>
|
||||
@@ -1099,7 +1172,8 @@ export class ImageDecryptService {
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false,
|
||||
sessionDirectory = ''
|
||||
sessionDirectory = '',
|
||||
createTime?: number
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -1129,18 +1203,13 @@ export class ImageDecryptService {
|
||||
? existsSync(join(attachDir, sessionDirectory))
|
||||
? [sessionDirectory]
|
||||
: []
|
||||
: readdirSync(attachDir).filter(
|
||||
(name) => name.length === 32 && /^[a-f0-9]+$/i.test(name)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
: readdirSync(attachDir).filter((name) => name.length === 32 && /^[a-f0-9]+$/i.test(name))
|
||||
|
||||
for (const sessDir of sessionDirs) {
|
||||
const sessionRoot = join(attachDir, sessDir)
|
||||
const months = sessionDirectory
|
||||
? this.getImageMonthDirectories(sessionRoot, createTime)
|
||||
: this.getRecentImageMonths(24)
|
||||
for (const month of months) {
|
||||
for (const sub of ['Img', 'Image', 'image']) {
|
||||
const imgDir = join(attachDir, sessDir, month, sub)
|
||||
@@ -1603,6 +1672,101 @@ export class ImageDecryptService {
|
||||
return crypto.createHash('md5').update(value).digest('hex')
|
||||
}
|
||||
|
||||
private getImageMonth(createTime?: number): string {
|
||||
if (!createTime || !Number.isFinite(createTime)) return ''
|
||||
const date = new Date(createTime * 1000)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
private prioritizeImageMonth(months: string[], createTime?: number): string[] {
|
||||
const preferred = this.getImageMonth(createTime)
|
||||
if (!preferred || !months.includes(preferred)) return months
|
||||
return [preferred, ...months.filter((month) => month !== preferred)]
|
||||
}
|
||||
|
||||
private getImageMonthDirectories(root: string, createTime?: number): string[] {
|
||||
try {
|
||||
const months = readdirSync(root)
|
||||
.filter((name) => /^\d{4}-\d{2}$/.test(name) && existsSync(join(root, name)))
|
||||
.sort((left, right) => right.localeCompare(left))
|
||||
return this.prioritizeImageMonth(months, createTime)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getRecentImageMonths(count: number): string[] {
|
||||
const now = new Date()
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - index, 1)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
})
|
||||
}
|
||||
|
||||
private findBubblePreview(
|
||||
accountDir: string,
|
||||
imageKeys: string[],
|
||||
sessionDirectory: string,
|
||||
createTime?: number
|
||||
): string | null {
|
||||
const cacheRoot = join(accountDir, 'cache')
|
||||
const months = this.getImageMonthDirectories(cacheRoot, createTime)
|
||||
const previewNames = imageKeys.flatMap((key) => [
|
||||
`${key}_b.dat`,
|
||||
`${key}_w.dat`,
|
||||
`${key}_c.dat`,
|
||||
`${key}_t_M.dat`,
|
||||
`${key}_t.dat`
|
||||
])
|
||||
for (const month of months) {
|
||||
const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble')
|
||||
const found = this.getLargestExistingPath(
|
||||
previewNames.map((name) => join(bubbleDir, name)),
|
||||
true,
|
||||
true
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async findBubblePreviewAsync(
|
||||
accountDir: string,
|
||||
imageKeys: string[],
|
||||
sessionDirectory: string,
|
||||
createTime?: number
|
||||
): Promise<string | null> {
|
||||
const cacheRoot = join(accountDir, 'cache')
|
||||
let months: string[]
|
||||
try {
|
||||
months = (await fsPromises.readdir(cacheRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => right.localeCompare(left))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
months = this.prioritizeImageMonth(months, createTime)
|
||||
const previewNames = imageKeys.flatMap((key) => [
|
||||
`${key}_b.dat`,
|
||||
`${key}_w.dat`,
|
||||
`${key}_c.dat`,
|
||||
`${key}_t_M.dat`,
|
||||
`${key}_t.dat`
|
||||
])
|
||||
for (const month of months) {
|
||||
const bubbleDir = join(cacheRoot, month, 'Message', sessionDirectory, 'Bubble')
|
||||
const found = await this.getLargestExistingPathAsync(
|
||||
previewNames.map((name) => join(bubbleDir, name)),
|
||||
true,
|
||||
true
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private buildPreferredDatNames(baseName: string): string[] {
|
||||
const base = this.normalizeDatBase(baseName)
|
||||
if (!base) return []
|
||||
@@ -1697,16 +1861,14 @@ export class ImageDecryptService {
|
||||
const thumbnail = sized.find((entry) => this.isThumbnailName(basename(entry.candidate)))
|
||||
if (thumbnail) return thumbnail.candidate
|
||||
}
|
||||
const nonThumbnail = sized.find(
|
||||
(entry) => !this.isThumbnailName(basename(entry.candidate))
|
||||
)
|
||||
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate)))
|
||||
if (nonThumbnail) return nonThumbnail.candidate
|
||||
return allowThumbnail ? sized[0]?.candidate || null : null
|
||||
}
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb)\.dat$/i.test(lower)
|
||||
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
|
||||
}
|
||||
|
||||
isThumbnailFile(filePath: string): boolean {
|
||||
|
||||
+8
-5
@@ -84,6 +84,7 @@ import {
|
||||
getCachedMessages,
|
||||
mergeBootstrapAvatars,
|
||||
mergeCachedContactAvatars,
|
||||
mergeCachedSelfInfo,
|
||||
saveBootstrapContacts,
|
||||
saveBootstrapSelf,
|
||||
saveCachedGroupSnapshot,
|
||||
@@ -1221,9 +1222,10 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:getSelf', () => {
|
||||
const info = chat.getSelfAccountInfo()
|
||||
if (!info) return { ready: false }
|
||||
ipcMain.handle('settings:getSelf', async () => {
|
||||
const rawInfo = await chat.getSelfAccountInfoAsync()
|
||||
if (!rawInfo) return { ready: false }
|
||||
const info = mergeCachedSelfInfo(rawInfo.accountRoot, rawInfo)
|
||||
if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info)
|
||||
return { ready: true, info }
|
||||
})
|
||||
@@ -1232,7 +1234,7 @@ app.whenReady().then(async () => {
|
||||
return chat.testConnection(key, accountRoot)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
|
||||
const ok = chat.reopenWithRoot(accountRoot)
|
||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||
@@ -1240,7 +1242,8 @@ app.whenReady().then(async () => {
|
||||
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
|
||||
saveSettings({ ...settings, imageKeyRoot: accountRoot })
|
||||
}
|
||||
const info = chat.getSelfAccountInfo()
|
||||
const rawInfo = await chat.getSelfAccountInfoAsync()
|
||||
const info = rawInfo ? mergeCachedSelfInfo(rawInfo.accountRoot, rawInfo) : null
|
||||
return { success: true, info }
|
||||
})
|
||||
|
||||
|
||||
@@ -428,6 +428,37 @@ function isRawContactName(contact: Contact): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function accountRootCandidates(accountRoot: string): Set<string> {
|
||||
const directory = path.basename(normalizeRoot(accountRoot))
|
||||
const suffixMatch = directory.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return new Set([directory, suffixMatch?.[1] || ''].filter(Boolean))
|
||||
}
|
||||
|
||||
export function mergeCachedSelfInfo(accountRoot: string, self: CachedSelfInfo): CachedSelfInfo {
|
||||
const cache = readStartupCacheFile(accountRoot)
|
||||
if (!cache) return self
|
||||
const identifiers = accountRootCandidates(accountRoot)
|
||||
if (self.wxid) identifiers.add(self.wxid)
|
||||
const isRawSelfName = (value?: string): boolean => {
|
||||
const name = String(value || '').trim()
|
||||
return !name || name === '我' || identifiers.has(name)
|
||||
}
|
||||
if (!isRawSelfName(self.nickname)) return self
|
||||
|
||||
const cachedContact = cache.contacts.find(
|
||||
(contact) => identifiers.has(contact.m_nsUsrName) && !isRawContactName(contact)
|
||||
)
|
||||
const cachedNickname = !isRawSelfName(cache.self?.nickname)
|
||||
? cache.self?.nickname
|
||||
: cachedContact?.m_nsNickName
|
||||
if (!cachedNickname) return self
|
||||
return {
|
||||
...self,
|
||||
nickname: cachedNickname,
|
||||
avatar: self.avatar || cache.self?.avatar || cachedContact?.avatar
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readStartupCacheFile(accountRoot)
|
||||
if (!cache?.contacts.length) return contacts
|
||||
|
||||
@@ -565,6 +565,18 @@ export function getSelfAccountInfo(): SelfAccountInfo | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSelfAccountInfoAsync(): Promise<SelfAccountInfo | null> {
|
||||
const current = dbRef
|
||||
if (!current) return null
|
||||
try {
|
||||
await current.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: true })
|
||||
} catch {
|
||||
// Nickname hydration is best-effort; the synchronous fallback still returns the account id.
|
||||
}
|
||||
if (dbRef !== current) return getSelfAccountInfo()
|
||||
return getSelfAccountInfo()
|
||||
}
|
||||
|
||||
export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
|
||||
const probeKey = key.replace(/^0x/i, '').trim()
|
||||
if (!/^[0-9a-f]{64}$/i.test(probeKey)) {
|
||||
|
||||
@@ -124,14 +124,18 @@ export async function testImageDecryption(
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
sessionId: imageMessage.sessionId,
|
||||
sessionMd5: request.userMd5,
|
||||
createTime: imageMessage.createTime
|
||||
})) || undefined
|
||||
if (!filePath) {
|
||||
filePath =
|
||||
(await service.findImageFileAsync(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir,
|
||||
sessionId: imageMessage.sessionId
|
||||
sessionId: imageMessage.sessionId,
|
||||
sessionMd5: request.userMd5,
|
||||
createTime: imageMessage.createTime
|
||||
})) || undefined
|
||||
}
|
||||
if (!filePath) return finish(failure('FILE_NOT_FOUND', '图片文件不存在'))
|
||||
|
||||
+52
-21
@@ -1,9 +1,52 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import { isPackagedRuntime } from './runtime-mode'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
export type SilkWasmRuntimeLocation = {
|
||||
packagePath: string
|
||||
wasmPath: string
|
||||
source: 'unpacked' | 'resources' | 'asar' | 'development'
|
||||
}
|
||||
|
||||
export function getSilkWasmRuntimeLocations(options?: {
|
||||
packaged?: boolean
|
||||
resourcesPath?: string
|
||||
appPath?: string
|
||||
}): SilkWasmRuntimeLocation[] {
|
||||
const packaged = options?.packaged ?? isPackagedRuntime()
|
||||
const resourcesPath = options?.resourcesPath ?? process.resourcesPath
|
||||
const appPath = options?.appPath ?? app.getAppPath()
|
||||
const location = (
|
||||
packagePath: string,
|
||||
source: SilkWasmRuntimeLocation['source']
|
||||
): SilkWasmRuntimeLocation => ({
|
||||
packagePath,
|
||||
wasmPath: join(packagePath, 'lib', 'silk.wasm'),
|
||||
source
|
||||
})
|
||||
|
||||
if (!packaged) {
|
||||
return [location(join(appPath, 'node_modules', 'silk-wasm'), 'development')]
|
||||
}
|
||||
|
||||
return [
|
||||
location(join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm'), 'unpacked'),
|
||||
location(join(resourcesPath, 'node_modules', 'silk-wasm'), 'resources'),
|
||||
location(join(appPath, 'node_modules', 'silk-wasm'), 'asar')
|
||||
]
|
||||
}
|
||||
|
||||
export function findSilkWasmRuntimeLocation(
|
||||
locations: SilkWasmRuntimeLocation[]
|
||||
): SilkWasmRuntimeLocation | null {
|
||||
return locations.find((location) => existsSync(location.wasmPath)) || null
|
||||
}
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private voiceCache = new Map<string, string>()
|
||||
@@ -101,35 +144,23 @@ export class VoiceService {
|
||||
|
||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||
try {
|
||||
let wasmPath: string
|
||||
if (isPackagedRuntime()) {
|
||||
wasmPath = join(
|
||||
process.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'silk-wasm',
|
||||
'lib',
|
||||
'silk.wasm'
|
||||
const locations = getSilkWasmRuntimeLocations()
|
||||
const runtime = findSilkWasmRuntimeLocation(locations)
|
||||
if (!runtime) {
|
||||
console.error(
|
||||
'[VoiceService] silk.wasm not found. checked:',
|
||||
locations.map((location) => location.wasmPath)
|
||||
)
|
||||
if (!existsSync(wasmPath)) {
|
||||
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
} else {
|
||||
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||
}
|
||||
|
||||
if (!existsSync(wasmPath)) {
|
||||
console.error('[VoiceService] silk.wasm not found at:', wasmPath)
|
||||
return null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const silkWasm = require('silk-wasm')
|
||||
const silkWasm = nodeRequire(runtime.packagePath)
|
||||
if (!silkWasm || !silkWasm.decode) {
|
||||
console.error('[VoiceService] silk-wasm module invalid')
|
||||
console.error('[VoiceService] silk-wasm module invalid:', runtime.packagePath)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('[VoiceService] using silk-wasm runtime:', runtime.source)
|
||||
const result = await silkWasm.decode(silkData, sampleRate)
|
||||
return Buffer.from(result.data)
|
||||
} catch (e) {
|
||||
|
||||
@@ -610,9 +610,11 @@ function App(): React.ReactElement {
|
||||
setDbKeyStatus('已连接数据库')
|
||||
// The cached list paints first. Refresh lightweight session flags and
|
||||
// missing avatars after the database is connected.
|
||||
void loadContacts({ waitForAvatars: false }).catch((error) => {
|
||||
console.warn('[Startup] background contact refresh failed:', error)
|
||||
})
|
||||
void loadContacts({ waitForAvatars: false })
|
||||
.then(() => refreshSelfInfo(3))
|
||||
.catch((error) => {
|
||||
console.warn('[Startup] background contact refresh failed:', error)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Startup] background database init failed:', error)
|
||||
@@ -638,7 +640,8 @@ function App(): React.ReactElement {
|
||||
if (hasBootstrap) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
await Promise.all([loadContacts(), refreshSelfInfo(3)])
|
||||
await loadContacts()
|
||||
await refreshSelfInfo(3)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
} else {
|
||||
@@ -761,13 +764,14 @@ function App(): React.ReactElement {
|
||||
if (hasBootstrap) {
|
||||
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||
setIsAuthenticated(true)
|
||||
void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch(
|
||||
(error) => {
|
||||
void loadContacts({ waitForAvatars: false })
|
||||
.then(() => refreshSelfInfo(3))
|
||||
.catch((error) => {
|
||||
console.warn('[Startup] background refresh failed:', error)
|
||||
}
|
||||
)
|
||||
})
|
||||
} else {
|
||||
await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)])
|
||||
await loadContacts({ waitForAvatars: false })
|
||||
await refreshSelfInfo(3)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
setStartupProgress({
|
||||
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
|
||||
|
||||
interface ImageDecoderRequirementNoticeProps {
|
||||
status?: ImageDecoderStatus
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
export function ImageDecoderRequirementNotice({
|
||||
status,
|
||||
onNotice
|
||||
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
|
||||
const platform = window.electron.process.platform
|
||||
const [currentStatus, setCurrentStatus] = useState(status)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => setCurrentStatus(status), [status])
|
||||
|
||||
const selectDirectory = async (): Promise<void> => {
|
||||
setSelecting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.selectImageDecoder()
|
||||
if (result.canceled) return
|
||||
if (!result.success || !result.status) {
|
||||
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
|
||||
return
|
||||
}
|
||||
setCurrentStatus(result.status)
|
||||
onNotice(
|
||||
result.status.available
|
||||
? 'FFmpeg 安装目录已保存,原图支持可以使用'
|
||||
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
|
||||
)
|
||||
} catch {
|
||||
setError('无法打开目录选择窗口,请稍后重试。')
|
||||
} finally {
|
||||
setSelecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkOriginalSupport = async (): Promise<void> => {
|
||||
setChecking(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextStatus = await window.api.getImageDecoderStatus()
|
||||
setCurrentStatus(nextStatus)
|
||||
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
|
||||
} catch {
|
||||
setError('原图支持检测失败,请稍后重试。')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDownload = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.openImageDecoderDownload()
|
||||
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
|
||||
} catch {
|
||||
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
|
||||
}
|
||||
}
|
||||
|
||||
const installed = currentStatus?.installed === true
|
||||
const supported = currentStatus?.available === true
|
||||
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
|
||||
aria-label="FFmpeg 原图支持"
|
||||
>
|
||||
<span className="image-decoder-requirement-icon" aria-hidden>
|
||||
{supported ? '✓' : '!'}
|
||||
</span>
|
||||
<div className="image-decoder-requirement-body">
|
||||
<div className="image-decoder-requirement-heading">
|
||||
<strong>FFmpeg 原图支持</strong>
|
||||
<span>
|
||||
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>如需打开少量 wxgf/HEVC 特殊原图,必须安装 FFmpeg。未安装不会影响聊天记录和普通图片。</p>
|
||||
|
||||
<div className="image-decoder-checks">
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
|
||||
<div>
|
||||
<strong>FFmpeg 安装目录</strong>
|
||||
<small title={currentStatus?.directory}>
|
||||
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
|
||||
<div>
|
||||
<strong>原图支持检测</strong>
|
||||
<small>
|
||||
{supported
|
||||
? '已支持 wxgf/HEVC 特殊原图转换'
|
||||
: installed
|
||||
? '尚未检测到 HEVC 原图转换能力'
|
||||
: '请先安装并检测 FFmpeg 目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-requirement-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
onClick={() => void openDownload()}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={selecting}
|
||||
onClick={() => void selectDirectory()}
|
||||
>
|
||||
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
disabled={!installed || checking}
|
||||
onClick={() => void checkOriginalSupport()}
|
||||
>
|
||||
{checking ? '正在检测…' : '检测原图支持'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-platform-help">
|
||||
{platform === 'win32' ? (
|
||||
<>
|
||||
<strong>Windows 安装与定位</strong>
|
||||
<p>
|
||||
下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在
|
||||
PowerShell 输入 <code>(Get-Command ffmpeg).Source</code>,或在命令提示符(CMD)输入{' '}
|
||||
<code>where ffmpeg</code>,然后选择返回路径所在的 bin 文件夹。
|
||||
</p>
|
||||
</>
|
||||
) : platform === 'darwin' ? (
|
||||
<>
|
||||
<strong>macOS 安装与定位</strong>
|
||||
<p>
|
||||
打开“终端”并输入 <code>brew install ffmpeg</code>。如果提示没有
|
||||
brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '}
|
||||
<code>which ffmpeg</code>,再选择返回路径所在的文件夹, 通常是{' '}
|
||||
<code>/opt/homebrew/bin</code> 或 <code>/usr/local/bin</code>。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Linux 安装与定位</strong>
|
||||
<p>
|
||||
通过系统包管理器安装 FFmpeg,执行 <code>which ffmpeg</code>{' '}
|
||||
查看位置,再选择返回路径所在的文件夹。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="image-decoder-requirement-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
||||
import { DangerZone } from '../image-decryption/DangerZone'
|
||||
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
|
||||
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
||||
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
||||
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
||||
@@ -56,11 +55,6 @@ export function ImageDecryptionPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImageDecoderRequirementNotice
|
||||
status={controller.state.status?.decoder}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">图片解密状态</h2>
|
||||
<ImageDecryptStatus
|
||||
state={controller.state}
|
||||
|
||||
@@ -956,170 +956,6 @@
|
||||
.image-decryption-content {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
.image-decoder-requirement {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid #ead2a8;
|
||||
border-radius: 8px;
|
||||
background: #fff9ee;
|
||||
color: #4b4439;
|
||||
}
|
||||
.image-decoder-requirement.ready {
|
||||
border-color: #bfddd2;
|
||||
background: #f2f8f5;
|
||||
}
|
||||
.image-decoder-requirement-icon {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 22px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #b87524;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-icon {
|
||||
background: #247a63;
|
||||
}
|
||||
.image-decoder-requirement-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.image-decoder-requirement-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.image-decoder-requirement-heading strong {
|
||||
color: #61451f;
|
||||
font-size: 14px;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-heading strong {
|
||||
color: #294b41;
|
||||
}
|
||||
.image-decoder-requirement-heading span {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(184, 117, 36, 0.1);
|
||||
color: #8b5d23;
|
||||
font-size: 11px;
|
||||
}
|
||||
.image-decoder-requirement.ready .image-decoder-requirement-heading span {
|
||||
background: #e1f0ea;
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-requirement p {
|
||||
margin: 7px 0 12px;
|
||||
color: #6d665c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.image-decoder-requirement.ready p {
|
||||
color: #5d6d66;
|
||||
}
|
||||
.image-decoder-checks {
|
||||
margin: 2px 0 14px;
|
||||
border-top: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-checks > div {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-check-index {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(184, 117, 36, 0.12);
|
||||
color: #8b5d23;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-decoder-check-index.complete {
|
||||
background: #e1f0ea;
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-checks strong,
|
||||
.image-decoder-checks small {
|
||||
display: block;
|
||||
}
|
||||
.image-decoder-checks strong {
|
||||
color: #4f4a42;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decoder-checks small {
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: #81796d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.image-decoder-checks b {
|
||||
color: #9a7750;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-decoder-checks b.success {
|
||||
color: #247a63;
|
||||
}
|
||||
.image-decoder-requirement-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.image-decoder-requirement-actions button {
|
||||
min-height: 34px;
|
||||
}
|
||||
.image-decoder-requirement small {
|
||||
display: block;
|
||||
color: #81796d;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.image-decoder-platform-help {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(139, 103, 47, 0.16);
|
||||
}
|
||||
.image-decoder-platform-help strong {
|
||||
color: #5b5145;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decoder-platform-help p {
|
||||
margin: 5px 0 0;
|
||||
color: #71695f;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.image-decoder-platform-help code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(117, 82, 31, 0.1);
|
||||
color: #68491f;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.image-decoder-requirement .image-decoder-requirement-error {
|
||||
margin: 10px 0 0;
|
||||
color: #b34848;
|
||||
font-size: 12px;
|
||||
}
|
||||
.image-decrypt-badge.unconfigured {
|
||||
background: #f1f3f2;
|
||||
color: #66706b;
|
||||
|
||||
Reference in New Issue
Block a user