mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
实现图片解密设置页
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
export interface StoredImageKeyEntry {
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface StoredImageKeyFile {
|
||||
version: 1
|
||||
accounts: Record<string, StoredImageKeyEntry>
|
||||
}
|
||||
|
||||
interface StoreReadResult {
|
||||
success: boolean
|
||||
data?: StoredImageKeyFile
|
||||
error?: string
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export class ImageKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-image-keys.bin')
|
||||
}
|
||||
|
||||
get(accountId: string): StoreReadResult & { entry?: StoredImageKeyEntry } {
|
||||
const result = this.read()
|
||||
return { ...result, entry: result.data?.accounts[accountId] }
|
||||
}
|
||||
|
||||
save(
|
||||
accountId: string,
|
||||
entry: Omit<StoredImageKeyEntry, 'updatedAt'>
|
||||
): { success: boolean; entry?: StoredImageKeyEntry; error?: string } {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success && fs.existsSync(this.filePath)) {
|
||||
return { success: false, error: '无法读取现有图片密钥配置' }
|
||||
}
|
||||
const nextEntry = { ...entry, updatedAt: Date.now() }
|
||||
const data: StoredImageKeyFile = current.data || { version: 1, accounts: {} }
|
||||
data.accounts[accountId] = nextEntry
|
||||
const saved = this.write(data)
|
||||
return saved.success ? { success: true, entry: nextEntry } : saved
|
||||
}
|
||||
|
||||
clear(accountId: string): { success: boolean; error?: string } {
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
if (!current.data?.accounts[accountId]) return { success: true }
|
||||
delete current.data.accounts[accountId]
|
||||
try {
|
||||
if (Object.keys(current.data.accounts).length === 0) fs.removeSync(this.filePath)
|
||||
else return this.write(current.data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法清除图片密钥配置' }
|
||||
}
|
||||
}
|
||||
|
||||
private read(): StoreReadResult {
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { version: 1, accounts: {} },
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const decrypted = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
const data = JSON.parse(decrypted) as StoredImageKeyFile
|
||||
if (data.version !== 1 || !data.accounts) throw new Error('invalid image key store')
|
||||
return { success: true, data, encryptionAvailable }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥安全存储不可读取', encryptionAvailable }
|
||||
}
|
||||
}
|
||||
|
||||
private write(data: StoredImageKeyFile): { success: boolean; error?: string } {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
|
||||
mode: 0o600
|
||||
})
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥保存失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-28
@@ -28,6 +28,7 @@ import {
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
@@ -35,6 +36,11 @@ import { apiServer } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWindowsWechatRunning } from './services/wechat-process-status'
|
||||
import {
|
||||
inspectImageDecryptionStatus,
|
||||
testImageDecryption
|
||||
} from './services/image-decryption-status-service'
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
getBootstrapCache,
|
||||
@@ -56,6 +62,7 @@ let voiceService: VoiceService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
let tray: Tray | null = null
|
||||
@@ -69,24 +76,11 @@ const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
const TRAY_MODE =
|
||||
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||
|
||||
function normalizeImageXorKey(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
const parsed = raw.toLowerCase().startsWith('0x')
|
||||
? Number.parseInt(raw.slice(2), 16)
|
||||
: Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return raw
|
||||
return `0x${Math.max(0, parsed & 0xff)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
||||
const settings = loadSettings()
|
||||
const config = imageKeyConfigService.getConfig()
|
||||
return {
|
||||
xorKey: settings.imageXorKey || import.meta.env.VITE_IMAGE_XOR_KEY || '0x40',
|
||||
aesKey: settings.imageAesKey || import.meta.env.VITE_IMAGE_AES_KEY || ''
|
||||
xorKey: config.xorKey || '0x40',
|
||||
aesKey: config.aesKey || ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +241,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event) => {
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
|
||||
@@ -261,22 +255,57 @@ app.whenReady().then(async () => {
|
||||
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
|
||||
|
||||
if (!result.success || !result.aesKey) return result
|
||||
if (process.platform === 'win32') {
|
||||
onStatus('发现候选密钥,图片模板验证通过')
|
||||
}
|
||||
|
||||
const imageXorKey = normalizeImageXorKey(result.xorKey)
|
||||
const nextSettings = saveSettings({
|
||||
...settings,
|
||||
imageXorKey,
|
||||
imageAesKey: result.aesKey
|
||||
const imageXorKey = `0x${Number(result.xorKey ?? 0x40)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(2, '0')}`
|
||||
const verified = result.verified ?? process.platform === 'win32'
|
||||
if (options?.save === false) {
|
||||
return { ...result, verified, imageXorKey, imageAesKey: result.aesKey }
|
||||
}
|
||||
const saved = imageKeyConfigService.save({
|
||||
resourceRoot: accountRoot,
|
||||
xorKey: imageXorKey,
|
||||
aesKey: result.aesKey
|
||||
})
|
||||
imageDecryptService = null
|
||||
if (saved.success) imageDecryptService = null
|
||||
return {
|
||||
...result,
|
||||
success: saved.success,
|
||||
error: saved.success ? undefined : saved.error,
|
||||
verified,
|
||||
imageXorKey,
|
||||
imageAesKey: result.aesKey,
|
||||
settings: nextSettings
|
||||
settings: imageKeyConfigService.getLegacySettingsView()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('image:getConfig', () => imageKeyConfigService.getConfig())
|
||||
|
||||
ipcMain.handle('image:getStatus', async () =>
|
||||
inspectImageDecryptionStatus(imageKeyConfigService.getConfig())
|
||||
)
|
||||
|
||||
ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => {
|
||||
const result = imageKeyConfigService.save(request)
|
||||
if (result.success) imageDecryptService = null
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('image:testConfig', (_, request: TestImageDecryptionRequest) =>
|
||||
testImageDecryption(request)
|
||||
)
|
||||
|
||||
ipcMain.handle('image:clearConfig', () => {
|
||||
const result = imageKeyConfigService.clear()
|
||||
if (result.success) imageDecryptService = null
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getBootstrapCache', () => {
|
||||
if (!chat.isReady()) return null
|
||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||
@@ -481,17 +510,29 @@ app.whenReady().then(async () => {
|
||||
// -------- Settings & API service --------
|
||||
|
||||
ipcMain.handle('settings:get', () => ({
|
||||
settings: loadSettings(),
|
||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||
settingsPath: getSettingsPath()
|
||||
}))
|
||||
|
||||
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
|
||||
const before = loadSettings()
|
||||
const merged = saveSettings({ ...before, ...patch })
|
||||
if (before.imageXorKey !== merged.imageXorKey || before.imageAesKey !== merged.imageAesKey) {
|
||||
const current = imageKeyConfigService.getConfig()
|
||||
const resourceRoot = patch.imageKeyRoot ?? before.imageKeyRoot
|
||||
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
||||
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
||||
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
||||
if (includesImageKey) {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
||||
else imageKeyConfigService.clear()
|
||||
imageDecryptService = null
|
||||
} else {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
}
|
||||
return {
|
||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||
settingsPath: getSettingsPath()
|
||||
}
|
||||
return { settings: merged, settingsPath: getSettingsPath() }
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:getSelf', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService } from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
|
||||
export async function inspectImageDecryptionStatus(
|
||||
config: ImageKeyConfigResult
|
||||
): Promise<ImageDecryptionStatus> {
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||
const stickerCacheFound =
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||
const dbConnected = chat.isReady()
|
||||
|
||||
return {
|
||||
configured: config.configured,
|
||||
saved: config.saved,
|
||||
encryptionAvailable: config.encryptionAvailable,
|
||||
source: config.source,
|
||||
accountId: config.accountId,
|
||||
resourceRoot: accountRoot,
|
||||
updatedAt: config.updatedAt,
|
||||
platform: process.platform,
|
||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||
wechatRunning: await isWechatRunning(),
|
||||
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
||||
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
||||
resources: {
|
||||
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
||||
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
||||
thumbnail: pending(imageDirectoryFound),
|
||||
original: pending(imageDirectoryFound),
|
||||
sticker: stickerCacheFound
|
||||
? check(true, '本地缓存可用')
|
||||
: { state: 'unknown', detail: '独立按需解析' },
|
||||
video: { state: 'unavailable', detail: '当前版本未提供视频媒体解析' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function testImageDecryption(
|
||||
request: TestImageDecryptionRequest
|
||||
): ImageDecryptionTestResult {
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return failure('NOT_CONFIGURED', normalized.error)
|
||||
if (!chat.isReady() || !request.userMd5) {
|
||||
return failure('NO_CONVERSATION', '请选择已连接账号中的聊天记录')
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = chat.listMessages(request.userMd5, undefined, undefined, { limit: 300 })
|
||||
const imageMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
||||
}
|
||||
|
||||
const service = new ImageDecryptService(
|
||||
normalized.xorKey,
|
||||
normalized.aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
return {
|
||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
||||
fileFound: true
|
||||
}
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
return {
|
||||
success: readable,
|
||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
||||
error: readable ? undefined : '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
} catch {
|
||||
return failure('UNKNOWN', '图片解析测试未通过')
|
||||
}
|
||||
}
|
||||
|
||||
function hasImageDirectory(accountRoot: string): boolean {
|
||||
if (!accountRoot) return false
|
||||
return [
|
||||
path.join(accountRoot, 'msg', 'attach'),
|
||||
path.join(accountRoot, 'FileStorage', 'Image'),
|
||||
path.join(accountRoot, 'FileStorage', 'Image2'),
|
||||
path.join(accountRoot, 'FileStorage', 'MsgImg')
|
||||
].some((candidate) => fs.existsSync(candidate))
|
||||
}
|
||||
|
||||
function canUseCacheRoot(): boolean {
|
||||
try {
|
||||
const cacheRoot = path.join(app.getPath('userData'), 'cache')
|
||||
fs.ensureDirSync(cacheRoot)
|
||||
return fs.statSync(cacheRoot).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function check(available: boolean, detail: string): ImageResourceCheck {
|
||||
return { state: available ? 'available' : 'unavailable', detail }
|
||||
}
|
||||
|
||||
function pending(directoryFound: boolean): ImageResourceCheck {
|
||||
return directoryFound
|
||||
? { state: 'unknown', detail: '通过图片解析测试确认' }
|
||||
: { state: 'unavailable', detail: '图片目录不可用' }
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: NonNullable<ImageDecryptionTestResult['code']>,
|
||||
error: string
|
||||
): ImageDecryptionTestResult {
|
||||
return { success: false, code, error, fileFound: false, decrypted: false, readable: false }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import path from 'path'
|
||||
import type { ImageKeyConfigResult, SaveImageKeyRequest } from '../../shared/image-decryption'
|
||||
import { ImageKeyStore } from '../image-key-store'
|
||||
import * as chat from './chat-service'
|
||||
import { loadSettings, saveSettings, type AppSettings } from './settings-store'
|
||||
|
||||
export class ImageKeyConfigService {
|
||||
constructor(private readonly store = new ImageKeyStore()) {}
|
||||
|
||||
getConfig(): ImageKeyConfigResult {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const stored = this.store.get(context.accountId)
|
||||
if (!stored.success) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot,
|
||||
error: stored.error
|
||||
}
|
||||
}
|
||||
if (stored.entry) {
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: stored.entry.xorKey,
|
||||
aesKey: stored.entry.aesKey,
|
||||
updatedAt: stored.entry.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.imageAesKey.trim()) {
|
||||
const legacy = this.buildResult({
|
||||
source: 'legacy-settings',
|
||||
xorKey: settings.imageXorKey || '0x40',
|
||||
aesKey: settings.imageAesKey,
|
||||
context,
|
||||
encryptionAvailable: stored.encryptionAvailable
|
||||
})
|
||||
if (!stored.encryptionAvailable) return legacy
|
||||
const migrated = this.save({
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: legacy.xorKey || '0x40',
|
||||
aesKey: legacy.aesKey || ''
|
||||
})
|
||||
return migrated.success ? this.getConfig() : legacy
|
||||
}
|
||||
|
||||
const envAesKey = String(import.meta.env.VITE_IMAGE_AES_KEY || '').trim()
|
||||
if (!settings.imageKeyFallbackDisabled && envAesKey) {
|
||||
return this.buildResult({
|
||||
source: 'environment',
|
||||
xorKey: String(import.meta.env.VITE_IMAGE_XOR_KEY || '0x40'),
|
||||
aesKey: envAesKey,
|
||||
context,
|
||||
encryptionAvailable: stored.encryptionAvailable
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot
|
||||
}
|
||||
}
|
||||
|
||||
save(request: SaveImageKeyRequest): ImageKeyConfigResult {
|
||||
const normalized = validateImageKeyRequest(request)
|
||||
if (!normalized.success) return { ...this.getEmptyConfig(), error: normalized.error }
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
if (!chat.getSelfAccountInfo()?.wxid) {
|
||||
return { ...this.getEmptyConfig(), error: '当前微信账号尚未识别' }
|
||||
}
|
||||
const result = this.store.save(context.accountId, {
|
||||
xorKey: normalized.xorKey,
|
||||
aesKey: normalized.aesKey
|
||||
})
|
||||
if (!result.success || !result.entry) {
|
||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||
}
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageKeyRoot: normalized.resourceRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: normalized.resourceRoot,
|
||||
xorKey: result.entry.xorKey,
|
||||
aesKey: result.entry.aesKey,
|
||||
updatedAt: result.entry.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
clear(): { success: boolean; error?: string } {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const cleared = this.store.clear(context.accountId)
|
||||
if (!cleared.success) return cleared
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: true
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
getLegacySettingsView(): AppSettings {
|
||||
const settings = loadSettings()
|
||||
const config = this.getConfig()
|
||||
return {
|
||||
...settings,
|
||||
imageXorKey: config.xorKey || '',
|
||||
imageAesKey: config.aesKey || ''
|
||||
}
|
||||
}
|
||||
|
||||
private getContext(settings: AppSettings): { accountId: string; resourceRoot: string } {
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const accountRoot = self?.accountRoot || chat.getCurrentAccountRoot() || settings.dbRoot
|
||||
return {
|
||||
accountId: self?.wxid || path.basename(accountRoot || '') || 'unbound',
|
||||
resourceRoot: settings.imageKeyRoot || accountRoot || settings.dbRoot
|
||||
}
|
||||
}
|
||||
|
||||
private buildResult(input: {
|
||||
source: 'legacy-settings' | 'environment'
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
context: { accountId: string; resourceRoot: string }
|
||||
encryptionAvailable: boolean
|
||||
}): ImageKeyConfigResult {
|
||||
return {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: false,
|
||||
encryptionAvailable: input.encryptionAvailable,
|
||||
source: input.source,
|
||||
accountId: input.context.accountId,
|
||||
resourceRoot: input.context.resourceRoot,
|
||||
xorKey: normalizeImageXorKey(input.xorKey),
|
||||
aesKey: input.aesKey.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private getEmptyConfig(): ImageKeyConfigResult {
|
||||
const settings = loadSettings()
|
||||
const context = this.getContext(settings)
|
||||
const stored = this.store.get(context.accountId)
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
saved: false,
|
||||
encryptionAvailable: stored.encryptionAvailable,
|
||||
source: 'none',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: context.resourceRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeImageXorKey(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return '0x40'
|
||||
const parsed = raw.toLowerCase().startsWith('0x')
|
||||
? Number.parseInt(raw.slice(2), 16)
|
||||
: Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 255) return raw
|
||||
return `0x${parsed.toString(16).toUpperCase().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function validateImageKeyRequest(
|
||||
request: SaveImageKeyRequest
|
||||
):
|
||||
| { success: true; resourceRoot: string; xorKey: string; aesKey: string }
|
||||
| { success: false; error: string } {
|
||||
const resourceRoot = request.resourceRoot.trim()
|
||||
const xorKey = normalizeImageXorKey(request.xorKey)
|
||||
const aesKey = request.aesKey.trim()
|
||||
if (!resourceRoot) return { success: false, error: '图片资源目录不能为空' }
|
||||
if (!/^0x[0-9A-F]{2}$/.test(xorKey)) return { success: false, error: 'XOR Key 格式不正确' }
|
||||
if (aesKey.length !== 16) return { success: false, error: 'AES Key 必须为 16 个字符' }
|
||||
return { success: true, resourceRoot, xorKey, aesKey }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface AppSettings {
|
||||
imageKeyRoot: string
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -21,7 +22,9 @@ function getDefaultDbRoot(): string {
|
||||
|
||||
function getDefaultDbRootCandidates(home: string): string[] {
|
||||
if (process.platform !== 'win32') {
|
||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||
return [
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||
]
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
@@ -113,8 +116,9 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 6131,
|
||||
imageKeyRoot: defaultDbRoot,
|
||||
imageXorKey: process.env.VITE_IMAGE_XOR_KEY || '',
|
||||
imageAesKey: process.env.VITE_IMAGE_AES_KEY || ''
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = path.join(
|
||||
|
||||
@@ -21,3 +21,21 @@ export async function isWindowsWechatRunning(): Promise<boolean> {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isWechatRunning(): Promise<boolean> {
|
||||
if (process.platform === 'win32') return isWindowsWechatRunning()
|
||||
if (process.platform !== 'darwin') return false
|
||||
|
||||
for (const args of [
|
||||
['-x', 'WeChat'],
|
||||
['-f', 'WeChat.app/Contents/MacOS/WeChat']
|
||||
]) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/pgrep', args)
|
||||
if (stdout.trim()) return true
|
||||
} catch {
|
||||
// Try the next macOS process lookup strategy.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user