mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
fix: 支持 wxgf 原图解密并修复缩略图缓存刷新
- 增加 wxgf/HEVC 图片转换支持 - 增加 FFmpeg 跨平台安装、目录填写与能力检测 - 避免缩略图占用原图缓存,下载原图后可即时刷新 - 移除聊天图片的缩略图角标 (cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
This commit is contained in:
@@ -3,7 +3,10 @@ import { existsSync, readFileSync, statSync, readdirSync, promises as fsPromises
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
|
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
|
import { loadSettings } from './services/settings-store'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||||
@@ -42,9 +45,111 @@ interface PersistentImageMeta {
|
|||||||
isThumbnail: boolean
|
isThumbnail: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FfmpegCandidate = { executable: string; source: ImageDecoderSource }
|
||||||
|
|
||||||
|
function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCandidate[] {
|
||||||
|
const selected = String(selectedPath || '').trim()
|
||||||
|
const environment = String(process.env['FFMPEG_BIN'] || '').trim()
|
||||||
|
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
const candidates: FfmpegCandidate[] = [
|
||||||
|
...(selected ? [{ executable: selected, source: 'selected' as const }] : []),
|
||||||
|
...(environment ? [{ executable: environment, source: 'environment' as const }] : []),
|
||||||
|
{
|
||||||
|
executable: join(process.resourcesPath, 'ffmpeg', executable),
|
||||||
|
source: 'bundled'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
executable: join(process.cwd(), 'resources', 'ffmpeg', executable),
|
||||||
|
source: 'bundled'
|
||||||
|
},
|
||||||
|
{ executable: process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', source: 'system' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return candidates.filter(
|
||||||
|
(candidate, index) =>
|
||||||
|
candidates.findIndex(
|
||||||
|
(other) => other.executable.toLowerCase() === candidate.executable.toLowerCase()
|
||||||
|
) === index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFfmpegExecutable(): string {
|
||||||
|
for (const candidate of getFfmpegCandidates()) {
|
||||||
|
if (candidate.source === 'environment' || candidate.source === 'system') {
|
||||||
|
return candidate.executable
|
||||||
|
}
|
||||||
|
if (existsSync(candidate.executable)) return candidate.executable
|
||||||
|
}
|
||||||
|
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function runImageDecoderCommand(
|
||||||
|
executable: string,
|
||||||
|
args: string[]
|
||||||
|
): Promise<{ success: boolean; output: string }> {
|
||||||
|
return new Promise((resolveValidation) => {
|
||||||
|
execFile(
|
||||||
|
executable,
|
||||||
|
args,
|
||||||
|
{ timeout: 7_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
resolveValidation({ success: !error, output: `${stdout}\n${stderr}` })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectImageDecoderExecutable(
|
||||||
|
executable: string
|
||||||
|
): Promise<{ installed: boolean; supportsHevc: boolean }> {
|
||||||
|
const version = await runImageDecoderCommand(executable, ['-hide_banner', '-version'])
|
||||||
|
if (!version.success || !/ffmpeg version/i.test(version.output)) {
|
||||||
|
return { installed: false, supportsHevc: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImageDecoderPath(executable: string): Promise<string | undefined> {
|
||||||
|
if (existsSync(executable)) return resolve(executable)
|
||||||
|
const locator = process.platform === 'win32' ? 'where.exe' : 'which'
|
||||||
|
const located = await runImageDecoderCommand(locator, [executable])
|
||||||
|
if (!located.success) return undefined
|
||||||
|
return located.output
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line && existsSync(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectImageDecoderStatus(
|
||||||
|
selectedPath = loadSettings().ffmpegPath
|
||||||
|
): Promise<ImageDecoderStatus> {
|
||||||
|
for (const candidate of getFfmpegCandidates(selectedPath)) {
|
||||||
|
const inspection = await inspectImageDecoderExecutable(candidate.executable)
|
||||||
|
if (inspection.installed) {
|
||||||
|
const resolvedPath = await resolveImageDecoderPath(candidate.executable)
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
available: inspection.supportsHevc,
|
||||||
|
source: candidate.source,
|
||||||
|
selected: candidate.source === 'selected',
|
||||||
|
directory: resolvedPath ? dirname(resolvedPath) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed: false, available: false, source: 'none', selected: false }
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_DECRYPT_WORKER_SOURCE = String.raw`
|
const IMAGE_DECRYPT_WORKER_SOURCE = String.raw`
|
||||||
const crypto = require('node:crypto')
|
const crypto = require('node:crypto')
|
||||||
|
const childProcess = require('node:child_process')
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const { parentPort, workerData } = require('node:worker_threads')
|
const { parentPort, workerData } = require('node:worker_threads')
|
||||||
|
|
||||||
@@ -105,7 +210,7 @@ function strictRemovePadding(buffer) {
|
|||||||
return buffer.subarray(0, buffer.length - paddingLength)
|
return buffer.subarray(0, buffer.length - paddingLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapWxgf(buffer) {
|
function unwrapWxgf(buffer, ffmpegPath) {
|
||||||
if (
|
if (
|
||||||
buffer.length < 20 ||
|
buffer.length < 20 ||
|
||||||
buffer[0] !== 0x77 ||
|
buffer[0] !== 0x77 ||
|
||||||
@@ -128,10 +233,55 @@ function unwrapWxgf(buffer) {
|
|||||||
return buffer.subarray(index)
|
return buffer.subarray(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buffer
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hevcOffset < 0 || !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))
|
||||||
|
childProcess.execFileSync(
|
||||||
|
ffmpegPath,
|
||||||
|
[
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-y',
|
||||||
|
'-f',
|
||||||
|
'hevc',
|
||||||
|
'-i',
|
||||||
|
inputPath,
|
||||||
|
'-frames:v',
|
||||||
|
'1',
|
||||||
|
outputPath
|
||||||
|
],
|
||||||
|
{ timeout: 20_000, windowsHide: true, stdio: 'ignore' }
|
||||||
|
)
|
||||||
|
const converted = fs.readFileSync(outputPath)
|
||||||
|
return detectImageExtension(converted) ? converted : buffer
|
||||||
|
} catch {
|
||||||
|
return buffer
|
||||||
|
} finally {
|
||||||
|
try { fs.rmSync(inputPath, { force: true }) } catch {}
|
||||||
|
try { fs.rmSync(outputPath, { force: true }) } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptCandidate(filePath, aesKey, xorKey) {
|
function decryptCandidate(filePath, aesKey, xorKey, ffmpegPath) {
|
||||||
const bytes = fs.readFileSync(filePath)
|
const bytes = fs.readFileSync(filePath)
|
||||||
if (!path.extname(filePath).toLowerCase().includes('dat')) {
|
if (!path.extname(filePath).toLowerCase().includes('dat')) {
|
||||||
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
|
const extension = detectImageExtension(bytes) || path.extname(filePath).toLowerCase()
|
||||||
@@ -176,7 +326,7 @@ function decryptCandidate(filePath, aesKey, xorKey) {
|
|||||||
xorPlain[index] = xorData[index] ^ xorKey
|
xorPlain[index] = xorData[index] ^ xorKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]))
|
const image = unwrapWxgf(Buffer.concat([unpadded, rawData, xorPlain]), ffmpegPath)
|
||||||
const extension = detectImageExtension(image)
|
const extension = detectImageExtension(image)
|
||||||
if (!extension) return null
|
if (!extension) return null
|
||||||
return {
|
return {
|
||||||
@@ -204,7 +354,12 @@ function collectCandidates(datPath, allowThumbnail) {
|
|||||||
let result = null
|
let result = null
|
||||||
for (const candidate of collectCandidates(workerData.datPath, workerData.allowThumbnail)) {
|
for (const candidate of collectCandidates(workerData.datPath, workerData.allowThumbnail)) {
|
||||||
try {
|
try {
|
||||||
result = decryptCandidate(candidate, workerData.aesKey, workerData.xorKey)
|
result = decryptCandidate(
|
||||||
|
candidate,
|
||||||
|
workerData.aesKey,
|
||||||
|
workerData.xorKey,
|
||||||
|
workerData.ffmpegPath
|
||||||
|
)
|
||||||
if (result) break
|
if (result) break
|
||||||
} catch {
|
} catch {
|
||||||
// Try the next local quality variant.
|
// Try the next local quality variant.
|
||||||
@@ -1163,7 +1318,8 @@ export class ImageDecryptService {
|
|||||||
datPath,
|
datPath,
|
||||||
allowThumbnail,
|
allowThumbnail,
|
||||||
xorKey: this.xorKey,
|
xorKey: this.xorKey,
|
||||||
aesKey: this.aesKey
|
aesKey: this.aesKey,
|
||||||
|
ffmpegPath: resolveFfmpegExecutable()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|||||||
+72
-4
@@ -11,7 +11,7 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
protocol
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { existsSync, promises as fsPromises } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
import { extname } from 'path'
|
import { extname } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
@@ -21,7 +21,12 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
|||||||
import { VoiceService } from './voice-service'
|
import { VoiceService } from './voice-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { parseMessageContent } from './message-parser'
|
import { parseMessageContent } from './message-parser'
|
||||||
import { ImageDecryptService, type DecodedImage } from './image-decrypt-service'
|
import {
|
||||||
|
ImageDecryptService,
|
||||||
|
inspectImageDecoderExecutable,
|
||||||
|
inspectImageDecoderStatus,
|
||||||
|
type DecodedImage
|
||||||
|
} from './image-decrypt-service'
|
||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import {
|
import {
|
||||||
deleteGeneratedReport,
|
deleteGeneratedReport,
|
||||||
@@ -659,6 +664,67 @@ app.whenReady().then(async () => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('image:selectDecoder', async (event) => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
const owner = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
const result = await dialog.showOpenDialog(owner!, {
|
||||||
|
title: '选择 FFmpeg 解压或安装目录',
|
||||||
|
defaultPath: settings.ffmpegPath ? dirname(settings.ffmpegPath) : app.getPath('downloads'),
|
||||||
|
properties: ['openDirectory']
|
||||||
|
})
|
||||||
|
if (result.canceled) return { success: false, canceled: true }
|
||||||
|
|
||||||
|
const selectedDirectory = result.filePaths[0]
|
||||||
|
if (!selectedDirectory) return { success: false, canceled: true }
|
||||||
|
|
||||||
|
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
const candidates = [
|
||||||
|
join(selectedDirectory, executable),
|
||||||
|
join(selectedDirectory, 'bin', executable)
|
||||||
|
]
|
||||||
|
let selectedPath = ''
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
const inspection = await inspectImageDecoderExecutable(candidate)
|
||||||
|
if (inspection.installed) {
|
||||||
|
selectedPath = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedPath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
canceled: false,
|
||||||
|
error: '所选目录中没有找到 FFmpeg,请选择解压后的文件夹或其中的 bin 文件夹。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings({ ...settings, ffmpegPath: selectedPath })
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
canceled: false,
|
||||||
|
status: await inspectImageDecoderStatus(selectedPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('image:getDecoderStatus', () => inspectImageDecoderStatus())
|
||||||
|
|
||||||
|
ipcMain.handle('image:openDecoderDownload', async () => {
|
||||||
|
const url =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? 'https://brew.sh/'
|
||||||
|
: 'https://ffmpeg.org/download.html'
|
||||||
|
try {
|
||||||
|
await shell.openExternal(url)
|
||||||
|
return { success: true }
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '无法打开下载页面,请检查系统默认浏览器设置。' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:getBootstrapCache', () => {
|
ipcMain.handle('db:getBootstrapCache', () => {
|
||||||
if (!chat.isReady()) return null
|
if (!chat.isReady()) return null
|
||||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||||
@@ -865,7 +931,7 @@ app.whenReady().then(async () => {
|
|||||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||||
includeData: !mediaService
|
includeData: !mediaService
|
||||||
})
|
})
|
||||||
if (cachedImage) {
|
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
||||||
return buildImageResponse(cachedImage)
|
return buildImageResponse(cachedImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,7 +960,9 @@ app.whenReady().then(async () => {
|
|||||||
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
||||||
includeData: !queuedMediaService
|
includeData: !queuedMediaService
|
||||||
})
|
})
|
||||||
if (queuedCacheHit) return buildImageResponse(queuedCacheHit)
|
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
||||||
|
return buildImageResponse(queuedCacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
let filePath = force
|
let filePath = force
|
||||||
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
|
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
ImageResourceCheck,
|
ImageResourceCheck,
|
||||||
TestImageDecryptionRequest
|
TestImageDecryptionRequest
|
||||||
} from '../../shared/image-decryption'
|
} from '../../shared/image-decryption'
|
||||||
import { ImageDecryptService } from '../image-decrypt-service'
|
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
|
||||||
import * as chat from './chat-service'
|
import * as chat from './chat-service'
|
||||||
import { validateImageKeyRequest } from './image-key-config-service'
|
import { validateImageKeyRequest } from './image-key-config-service'
|
||||||
import { isWechatRunning } from './wechat-process-status'
|
import { isWechatRunning } from './wechat-process-status'
|
||||||
@@ -25,6 +25,10 @@ export async function inspectImageDecryptionStatus(
|
|||||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||||
const dbConnected = chat.isReady()
|
const dbConnected = chat.isReady()
|
||||||
|
const [wechatRunning, decoder] = await Promise.all([
|
||||||
|
isWechatRunning(),
|
||||||
|
inspectImageDecoderStatus()
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configured: config.configured,
|
configured: config.configured,
|
||||||
@@ -36,9 +40,10 @@ export async function inspectImageDecryptionStatus(
|
|||||||
updatedAt: config.updatedAt,
|
updatedAt: config.updatedAt,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||||
wechatRunning: await isWechatRunning(),
|
wechatRunning,
|
||||||
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
||||||
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
||||||
|
decoder,
|
||||||
resources: {
|
resources: {
|
||||||
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
||||||
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AppSettings {
|
|||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
imageKeyFallbackDisabled: boolean
|
imageKeyFallbackDisabled: boolean
|
||||||
|
ffmpegPath: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
debugEnabled: boolean
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
@@ -108,6 +109,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false,
|
imageKeyFallbackDisabled: false,
|
||||||
|
ffmpegPath: '',
|
||||||
recallProtectionEnabled: false,
|
recallProtectionEnabled: false,
|
||||||
debugEnabled: false,
|
debugEnabled: false,
|
||||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
|||||||
Vendored
+9
@@ -14,6 +14,8 @@ import type {
|
|||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../shared/database-key'
|
} from '../shared/database-key'
|
||||||
import type {
|
import type {
|
||||||
|
ImageDecoderSelectionResult,
|
||||||
|
ImageDecoderStatus,
|
||||||
ImageDecryptionStatus,
|
ImageDecryptionStatus,
|
||||||
ImageDecryptionTestResult,
|
ImageDecryptionTestResult,
|
||||||
ImageKeyConfigResult,
|
ImageKeyConfigResult,
|
||||||
@@ -260,6 +262,7 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
ffmpegPath: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
debugEnabled: boolean
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
@@ -273,6 +276,9 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
||||||
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
||||||
|
selectImageDecoder: () => Promise<ImageDecoderSelectionResult>
|
||||||
|
getImageDecoderStatus: () => Promise<ImageDecoderStatus>
|
||||||
|
openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }>
|
||||||
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
||||||
testImageDecryption: (
|
testImageDecryption: (
|
||||||
request: TestImageDecryptionRequest
|
request: TestImageDecryptionRequest
|
||||||
@@ -291,6 +297,7 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
ffmpegPath: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
debugEnabled: boolean
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
@@ -310,6 +317,7 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
ffmpegPath: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
debugEnabled: boolean
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
@@ -327,6 +335,7 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
ffmpegPath: string
|
||||||
recallProtectionEnabled: boolean
|
recallProtectionEnabled: boolean
|
||||||
debugEnabled: boolean
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AppLogEntry } from '../shared/app-log'
|
|||||||
import type { AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
import type { CacheSummary } from '../shared/cache'
|
import type { CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
|
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -105,6 +106,12 @@ const api = {
|
|||||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||||
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
||||||
|
selectImageDecoder: (): Promise<ImageDecoderSelectionResult> =>
|
||||||
|
ipcRenderer.invoke('image:selectDecoder'),
|
||||||
|
getImageDecoderStatus: (): Promise<ImageDecoderStatus> =>
|
||||||
|
ipcRenderer.invoke('image:getDecoderStatus'),
|
||||||
|
openImageDecoderDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
ipcRenderer.invoke('image:openDecoderDownload'),
|
||||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function ImageBubble({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
|
||||||
const [usingFallback, setUsingFallback] = useState(false)
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
@@ -75,7 +74,6 @@ export function ImageBubble({
|
|||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setUsingFallback(false)
|
setUsingFallback(false)
|
||||||
setIsThumbnail(result.isThumbnail)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
@@ -145,7 +143,6 @@ export function ImageBubble({
|
|||||||
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
|
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setUsingFallback(false)
|
setUsingFallback(false)
|
||||||
setIsThumbnail(result.isThumbnail)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
onImageClick?.(result.data)
|
onImageClick?.(result.data)
|
||||||
return
|
return
|
||||||
@@ -193,9 +190,7 @@ export function ImageBubble({
|
|||||||
alt="图片"
|
alt="图片"
|
||||||
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||||
/>
|
/>
|
||||||
{(upgrading || isThumbnail) && (
|
{upgrading && <div className="image-quality-badge">正在查找原图</div>}
|
||||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
|
||||||
)}
|
|
||||||
<div className="image-actions">
|
<div className="image-actions">
|
||||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||||
复制
|
复制
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ function getCachedImage(
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const cached = imageCache.get(key)
|
const cached = imageCache.get(key)
|
||||||
if (!cached) continue
|
if (!cached) continue
|
||||||
|
if (options.force && cached.isThumbnail) {
|
||||||
|
imageCache.delete(key)
|
||||||
|
imageCacheBytes -= imageCacheSizes.get(key) || 0
|
||||||
|
imageCacheSizes.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
imageCache.delete(key)
|
imageCache.delete(key)
|
||||||
imageCache.set(key, cached)
|
imageCache.set(key, cached)
|
||||||
return cached
|
return cached
|
||||||
@@ -81,6 +87,10 @@ function cacheImage(
|
|||||||
options: ImageLoadOptions,
|
options: ImageLoadOptions,
|
||||||
image: LoadedImage
|
image: LoadedImage
|
||||||
): void {
|
): void {
|
||||||
|
// A forced original request may temporarily fall back to a thumbnail. Do not
|
||||||
|
// let that fallback prevent a later retry after WeChat downloads the original.
|
||||||
|
if (options.force && image.isThumbnail) return
|
||||||
|
|
||||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||||
const size = image.data.length * 2
|
const size = image.data.length * 2
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
import type { SettingsSelfInfo } from '../model/types'
|
import type { SettingsSelfInfo } from '../model/types'
|
||||||
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
||||||
import { DangerZone } from '../image-decryption/DangerZone'
|
import { DangerZone } from '../image-decryption/DangerZone'
|
||||||
|
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
|
||||||
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
||||||
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
||||||
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
||||||
@@ -55,6 +56,11 @@ export function ImageDecryptionPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ImageDecoderRequirementNotice
|
||||||
|
status={controller.state.status?.decoder}
|
||||||
|
onNotice={onNotice}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">图片解密状态</h2>
|
<h2 className="settings-section-heading">图片解密状态</h2>
|
||||||
<ImageDecryptStatus
|
<ImageDecryptStatus
|
||||||
state={controller.state}
|
state={controller.state}
|
||||||
|
|||||||
@@ -956,6 +956,170 @@
|
|||||||
.image-decryption-content {
|
.image-decryption-content {
|
||||||
padding-bottom: 48px;
|
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 {
|
.image-decrypt-badge.unconfigured {
|
||||||
background: #f1f3f2;
|
background: #f1f3f2;
|
||||||
color: #66706b;
|
color: #66706b;
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none'
|
export type ImageKeySource = 'secure-storage' | 'legacy-settings' | 'environment' | 'none'
|
||||||
export type ImageResourceState = 'available' | 'unavailable' | 'unknown'
|
export type ImageResourceState = 'available' | 'unavailable' | 'unknown'
|
||||||
|
export type ImageDecoderSource = 'selected' | 'environment' | 'bundled' | 'system' | 'none'
|
||||||
|
|
||||||
|
export interface ImageDecoderStatus {
|
||||||
|
installed: boolean
|
||||||
|
available: boolean
|
||||||
|
source: ImageDecoderSource
|
||||||
|
selected: boolean
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageDecoderSelectionResult {
|
||||||
|
success: boolean
|
||||||
|
canceled: boolean
|
||||||
|
status?: ImageDecoderStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageKeyConfigResult {
|
export interface ImageKeyConfigResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -33,6 +49,7 @@ export interface ImageDecryptionStatus {
|
|||||||
wechatRunning: boolean
|
wechatRunning: boolean
|
||||||
accountIdentified: boolean
|
accountIdentified: boolean
|
||||||
cacheState: 'normal' | 'unavailable'
|
cacheState: 'normal' | 'unavailable'
|
||||||
|
decoder: ImageDecoderStatus
|
||||||
resources: {
|
resources: {
|
||||||
imageIndex: ImageResourceCheck
|
imageIndex: ImageResourceCheck
|
||||||
imageDirectory: ImageResourceCheck
|
imageDirectory: ImageResourceCheck
|
||||||
|
|||||||
Reference in New Issue
Block a user