Files
2026-09-28 21:16:43 +08:00

1207 lines
46 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { join } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
import { CacheMapStore } from './cacheMapStore'
// 条件导入 electron(Worker 环境中不可用)
let app: any = null
let safeStorage: any = null
const isWorkerThread = process.env.WEFLOW_WORKER === '1'
if (!isWorkerThread) {
try {
const electron = require('electron')
app = electron.app
safeStorage = electron.safeStorage
} catch {
// Worker 环境中 electron 不可用
}
}
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const isSafeStorageAvailable = (): boolean => {
try {
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
} catch {
return false
}
}
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema {
// 数据库相关
dbPath: string
decryptKey: string
myAccountId: string
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
accountConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
exportPath?: string;
// 第三方可插拔组件路径(用户自备实现,均不内置校验来源)
wcdbLibPath?: string
keyProviderPath?: string
imageNativeAddonPath?: string
welivePath?: string
imgHelperLibPath?: string
imgHelperProcessName?: string
// 目标应用的系统保留账号 ID(用于从会话列表/统计中排除系统通知账号),默认不过滤
systemAccountId?: string
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
// 界面相关
theme: 'light' | 'dark' | 'system'
themeId: string
language: string
logEnabled: boolean
launchAtStartup?: boolean
silentStartup?: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultPathStyle: 'auto' | 'posix' | 'windows'
exportDefaultDisplayNamePreference: 'group-nickname' | 'remark' | 'nickname'
analyticsExcludedUsernames: string[]
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hash(safeStorage 加密)
authUseHello: boolean
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
// 更新相关
ignoredUpdateVersion: string
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
// 通知
notificationEnabled: boolean
aiInsightNotificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
messagePushFilterList: string[]
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
httpApiToken: string
windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[]
exportWriteLayout: 'A' | 'B' | 'C'
exportAutomationTaskMap: Record<string, unknown>
// AI 见解
aiModelApiBaseUrl: string
aiModelApiKey: string
aiModelApiModel: string
aiModelApiMaxTokens: number
aiModelProfilesJson: string
activeAiModelProfileId: string
relayOneSessionJson: string
agentDebugLogEnabled: boolean
aiRelationshipJourneyAdjudicationEnabled: boolean
aiInsightEnabled: boolean
aiInsightApiBaseUrl: string
aiInsightApiKey: string
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[]
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
aiInsightCooldownMinutes: number
/** 沉默联系人扫描间隔(小时) */
aiInsightScanIntervalHours: number
/** 发送上下文时的最大消息条数 */
aiInsightContextCount: number
/** 自定义 system prompt,空字符串表示使用内置默认值 */
aiInsightSystemPrompt: string
/** 是否启用 Telegram 推送 */
aiInsightTelegramEnabled: boolean
/** Telegram Bot Token */
aiInsightTelegramToken: string
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
aiInsightTelegramChatIds: string
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
aiGroupSummaryEnabled: boolean
aiGroupSummaryIntervalHours: number
aiGroupSummarySystemPrompt: string
aiGroupSummaryFilterMode: 'whitelist' | 'blacklist'
aiGroupSummaryFilterList: string[]
aiMessageInsightEnabled: boolean
aiMessageInsightContextCount: number
aiMessageInsightSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'decryptKey',
'imageAesKey',
'authPassword',
'httpApiToken',
'aiModelApiKey',
'aiModelProfilesJson',
'relayOneSessionJson',
'aiInsightApiKey',
'aiInsightWeiboCookie'
])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'aiModelApiKey', 'aiModelProfilesJson'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
/**
* 大体积 UI 缓存键(以 CacheMap 结尾),存入独立的 CacheMapStore。
* conf 库对主配置文件的每次 get/set 都是全量同步读写,
* 这些缓存曾把配置文件撑到 3.2MB,导致高频主线程阻塞。
*/
const isCacheMapKey = (key: string): boolean => key.endsWith('CacheMap')
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
// Worker 环境不创建(CacheMap 键仅主进程访问,避免多进程并发写同一文件)
private cacheMapStore: CacheMapStore | null = null
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
// 账号目录缓存
private accountDirCache: Map<string, string> = new Map()
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
}
return ConfigService.instance
}
constructor() {
if (ConfigService.instance) {
return ConfigService.instance
}
ConfigService.instance = this
const defaults: ConfigSchema = {
dbPath: '',
decryptKey: '',
myAccountId: '',
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
accountConfigs: {},
wcdbLibPath: '',
keyProviderPath: '',
imageNativeAddonPath: '',
welivePath: '',
imgHelperLibPath: '',
imgHelperProcessName: '',
systemAccountId: '',
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
silentStartup: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultPathStyle: 'auto',
exportDefaultDisplayNamePreference: 'remark',
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
aiInsightNotificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
messagePushFilterMode: 'all',
messagePushFilterList: [],
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: [],
exportWriteLayout: 'A',
exportAutomationTaskMap: {},
aiModelApiBaseUrl: '',
aiModelApiKey: '',
aiModelApiModel: 'gpt-4o-mini',
aiModelApiMaxTokens: 1024,
aiModelProfilesJson: '',
activeAiModelProfileId: '',
relayOneSessionJson: '',
agentDebugLogEnabled: false,
aiRelationshipJourneyAdjudicationEnabled: false,
aiInsightEnabled: false,
aiInsightApiBaseUrl: '',
aiInsightApiKey: '',
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
aiInsightWhitelistEnabled: false,
aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120,
aiInsightScanIntervalHours: 4,
aiInsightContextCount: 40,
aiInsightSocialContextCount: 3,
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: '',
aiInsightWeiboCookie: '',
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiGroupSummaryEnabled: false,
aiGroupSummaryIntervalHours: 4,
aiGroupSummarySystemPrompt: '',
aiGroupSummaryFilterMode: 'whitelist',
aiGroupSummaryFilterList: [],
aiMessageInsightEnabled: false,
aiMessageInsightContextCount: 50,
aiMessageInsightSystemPrompt: '',
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
}
const storeOptions: any = {
name: 'WeFlow-config',
defaults,
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
}
const runningInWorker = process.env.WEFLOW_WORKER === '1'
if (runningInWorker) {
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
if (cwd) {
storeOptions.cwd = cwd
}
}
try {
this.store = new Store<ConfigSchema>(storeOptions)
} catch (error) {
const message = String((error as Error)?.message || error || '')
if (message.includes('projectName')) {
const fallbackOptions = {
...storeOptions,
projectName: 'WeFlow',
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
}
this.store = new Store<ConfigSchema>(fallbackOptions)
} else {
throw error
}
}
this.migrateAuthFields()
this.migrateAiConfig()
if (!runningInWorker) {
this.cacheMapStore = new CacheMapStore(this.getUserDataPath())
this.migrateCacheMapKeys()
}
}
/** 一次性迁移:把主配置中的 *CacheMap 大键搬到旁路存储,缩小配置文件 */
private migrateCacheMapKeys(): void {
if (!this.cacheMapStore) return
try {
const all = this.store.store as unknown as Record<string, unknown>
const cacheKeys = Object.keys(all).filter(isCacheMapKey)
if (cacheKeys.length === 0) return
for (const key of cacheKeys) {
this.cacheMapStore.set(key, all[key])
delete all[key]
}
// 先落盘旁路存储,再整体重写主配置(各一次全量写)
this.cacheMapStore.flushSync()
;(this.store as any).store = all
} catch (error) {
console.error('ConfigService: 迁移 CacheMap 键失败', error)
}
}
// === 状态查询 ===
isLockMode(): boolean {
const raw: any = this.store.get('decryptKey')
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) return true
const activeAiKey: any = this.store.get('aiModelApiKey')
if (typeof activeAiKey === 'string' && activeAiKey.startsWith(LOCK_PREFIX)) return true
const profiles: any = this.store.get('aiModelProfilesJson')
return typeof profiles === 'string' && profiles.startsWith(LOCK_PREFIX)
}
isUnlocked(): boolean {
return !this.isLockMode() || this.unlockedKeys.size > 0
}
/** Encrypt sensitive local analysis caches with the same lock boundary as configuration. */
protectLocalCachePayload(plaintext: string): string | null {
if (!plaintext) return null
if (this.isLockMode()) {
if (!this.unlockPassword) return null
return this.lockEncrypt(plaintext, this.unlockPassword)
}
const protectedValue = this.safeEncrypt(plaintext)
return protectedValue.startsWith(SAFE_PREFIX) ? protectedValue : null
}
/** Decrypt a cache payload only while the corresponding app security boundary is available. */
unprotectLocalCachePayload(stored: string): string | null {
if (!stored) return null
if (stored.startsWith(LOCK_PREFIX)) {
return this.unlockPassword ? this.lockDecrypt(stored, this.unlockPassword) : null
}
if (stored.startsWith(SAFE_PREFIX)) return this.safeDecrypt(stored) || null
return null
}
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
return this.cacheMapStore.get(key as string) as ConfigSchema[K]
}
const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
}
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str) return raw
if (str.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
}
if (!str.startsWith(SAFE_PREFIX)) return raw
const num = Number(this.safeDecrypt(str))
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
}
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
if (raw.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : '') as ConfigSchema[K]
}
return this.safeDecrypt(raw) as ConfigSchema[K]
}
if (key === 'accountConfigs' && raw && typeof raw === 'object') {
return this.decryptAccountConfigs(raw as any) as ConfigSchema[K]
}
if (key === 'dbPath' && typeof raw === 'string') {
return expandHomePath(raw) as ConfigSchema[K]
}
return raw
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
if (this.cacheMapStore && isCacheMapKey(key as string)) {
this.cacheMapStore.set(key as string, value)
return
}
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (key === 'dbPath' && typeof value === 'string') {
toStore = expandHomePath(value) as ConfigSchema[K]
}
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const boolValue = value === true || value === 'true'
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
}
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
if (key === 'authPassword') {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
}
} else if (key === 'accountConfigs' && value && typeof value === 'object') {
if (inLockMode) {
toStore = this.lockEncryptAccountConfigs(value as any) as ConfigSchema[K]
} else {
toStore = this.encryptAccountConfigs(value as any) as ConfigSchema[K]
}
}
this.store.set(key, toStore)
}
// === 加密/解密工具 ===
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!isSafeStorageAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!isSafeStorageAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
} catch {
return ''
}
}
private lockEncrypt(plaintext: string, password: string): string {
if (!plaintext) return ''
const salt = crypto.randomBytes(16)
const iv = crypto.randomBytes(12)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([salt, iv, authTag, encrypted])
return LOCK_PREFIX + combined.toString('base64')
}
private lockDecrypt(stored: string, password: string): string | null {
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
try {
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
const salt = combined.subarray(0, 16)
const iv = combined.subarray(16, 28)
const authTag = combined.subarray(28, 44)
const ciphertext = combined.subarray(44)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted.toString('utf8')
} catch {
return null
}
}
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
private verifyPasswordByDecrypt(password: string): boolean {
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey', 'aiModelApiKey'] as const
for (const key of lockFields) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
const result = this.lockDecrypt(raw, password)
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
return result !== null
}
}
const rawProfiles: any = this.store.get('aiModelProfilesJson')
if (typeof rawProfiles === 'string' && rawProfiles.startsWith(LOCK_PREFIX)) {
return this.lockDecrypt(rawProfiles, password) !== null
}
return false
}
// === accountConfigs 加密/解密 ===
private encryptAccountConfigs(configs: ConfigSchema['accountConfigs']): ConfigSchema['accountConfigs'] {
const result: ConfigSchema['accountConfigs'] = {}
for (const [accountId, cfg] of Object.entries(configs)) {
result[accountId] = { ...cfg }
if (cfg.decryptKey) result[accountId].decryptKey = this.safeEncrypt(cfg.decryptKey)
if (cfg.imageAesKey) result[accountId].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) {
(result[accountId] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
}
}
return result
}
private decryptLockedAccountConfigs(password: string): void {
const accountConfigs = this.store.get('accountConfigs')
if (!accountConfigs || typeof accountConfigs !== 'object') return
for (const [accountId, cfg] of Object.entries(accountConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.decryptKey, password)
if (d !== null) this.unlockedKeys.set(`accountId:${accountId}:decryptKey`, d)
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageAesKey, password)
if (d !== null) this.unlockedKeys.set(`accountId:${accountId}:imageAesKey`, d)
}
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageXorKey, password)
if (d !== null) this.unlockedKeys.set(`accountId:${accountId}:imageXorKey`, Number(d))
}
}
}
private decryptAccountConfigs(configs: ConfigSchema['accountConfigs']): ConfigSchema['accountConfigs'] {
const result: ConfigSchema['accountConfigs'] = {}
for (const [accountId, cfg] of Object.entries(configs) as [string, any][]) {
result[accountId] = { ...cfg, updatedAt: cfg.updatedAt }
// decryptKey
if (typeof cfg.decryptKey === 'string') {
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
result[accountId].decryptKey = this.unlockedKeys.get(`accountId:${accountId}:decryptKey`) ?? ''
} else {
result[accountId].decryptKey = this.safeDecrypt(cfg.decryptKey)
}
}
// imageAesKey
if (typeof cfg.imageAesKey === 'string') {
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
result[accountId].imageAesKey = this.unlockedKeys.get(`accountId:${accountId}:imageAesKey`) ?? ''
} else {
result[accountId].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
}
}
// imageXorKey
if (typeof cfg.imageXorKey === 'string') {
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
result[accountId].imageXorKey = this.unlockedKeys.get(`accountId:${accountId}:imageXorKey`) ?? 0
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
const num = Number(this.safeDecrypt(cfg.imageXorKey))
result[accountId].imageXorKey = Number.isFinite(num) ? num : 0
}
}
}
return result
}
private lockEncryptAccountConfigs(configs: ConfigSchema['accountConfigs']): ConfigSchema['accountConfigs'] {
const result: ConfigSchema['accountConfigs'] = {}
for (const [accountId, cfg] of Object.entries(configs)) {
result[accountId] = { ...cfg }
if (cfg.decryptKey) result[accountId].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
if (cfg.imageAesKey) result[accountId].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
if (cfg.imageXorKey !== undefined) {
(result[accountId] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
}
}
return result
}
// === 业务方法 ===
enableLock(password: string): { success: boolean; error?: string } {
try {
// 先读取当前所有明文密钥
const decryptKey = this.get('decryptKey')
const imageAesKey = this.get('imageAesKey')
const imageXorKey = this.get('imageXorKey')
const accountConfigs = this.get('accountConfigs')
const aiModelApiKey = this.get('aiModelApiKey')
const aiModelProfilesJson = this.get('aiModelProfilesJson')
// 存储密码 hash(safeStorage 加密)
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
// 设置运行时状态
this.unlockPassword = password
this.unlockedKeys.set('decryptKey', decryptKey)
this.unlockedKeys.set('imageAesKey', imageAesKey)
this.unlockedKeys.set('imageXorKey', imageXorKey)
this.unlockedKeys.set('aiModelApiKey', aiModelApiKey)
this.unlockedKeys.set('aiModelProfilesJson', aiModelProfilesJson)
// 用密码派生密钥重新加密所有敏感字段
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
if (aiModelApiKey) this.store.set('aiModelApiKey', this.lockEncrypt(String(aiModelApiKey), password) as any)
if (aiModelProfilesJson) this.store.set('aiModelProfilesJson', this.lockEncrypt(String(aiModelProfilesJson), password) as any)
// 处理 accountConfigs 中的嵌套密钥
if (accountConfigs && Object.keys(accountConfigs).length > 0) {
const lockedConfigs = this.lockEncryptAccountConfigs(accountConfigs)
this.store.set('accountConfigs', lockedConfigs)
for (const [accountId, cfg] of Object.entries(accountConfigs)) {
if (cfg.decryptKey) this.unlockedKeys.set(`accountId:${accountId}:decryptKey`, cfg.decryptKey)
if (cfg.imageAesKey) this.unlockedKeys.set(`accountId:${accountId}:imageAesKey`, cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`accountId:${accountId}:imageXorKey`, cfg.imageXorKey)
}
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
unlock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash && storedHash !== inputHash) {
// authPassword 存在但密码不匹配
return { success: false, error: '密码错误' }
}
if (!storedHash) {
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
const verified = this.verifyPasswordByDecrypt(password)
if (!verified) {
return { success: false, error: '密码错误' }
}
// 密码正确,自愈 authPassword
const newHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
}
// 解密所有 lock: 字段到内存缓存
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawDecryptKey, password)
if (d !== null) this.unlockedKeys.set('decryptKey', d)
}
const rawImageAesKey: any = this.store.get('imageAesKey')
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageAesKey, password)
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
}
const rawImageXorKey: any = this.store.get('imageXorKey')
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageXorKey, password)
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
}
const rawProfiles: any = this.store.get('aiModelProfilesJson')
if (typeof rawProfiles === 'string' && rawProfiles.startsWith(LOCK_PREFIX)) {
const decrypted = this.lockDecrypt(rawProfiles, password)
if (decrypted !== null) this.unlockedKeys.set('aiModelProfilesJson', decrypted)
}
const rawAiModelApiKey: any = this.store.get('aiModelApiKey')
if (typeof rawAiModelApiKey === 'string' && rawAiModelApiKey.startsWith(LOCK_PREFIX)) {
const decrypted = this.lockDecrypt(rawAiModelApiKey, password)
if (decrypted !== null) this.unlockedKeys.set('aiModelApiKey', decrypted)
}
// 解密 accountConfigs 嵌套密钥
this.decryptLockedAccountConfigs(password)
// 保留密码供 set() 使用
this.unlockPassword = password
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
disableLock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash !== inputHash) {
return { success: false, error: '密码错误' }
}
// 先解密所有 lock: 字段
if (this.unlockedKeys.size === 0) {
this.unlock(password)
}
// 将所有密钥转回 safe: 格式
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
const aiModelApiKey = this.unlockedKeys.get('aiModelApiKey')
const aiModelProfilesJson = this.unlockedKeys.get('aiModelProfilesJson')
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
if (aiModelApiKey) this.store.set('aiModelApiKey', this.safeEncrypt(String(aiModelApiKey)) as any)
if (aiModelProfilesJson) this.store.set('aiModelProfilesJson', this.safeEncrypt(String(aiModelProfilesJson)) as any)
// 转换 accountConfigs
const accountConfigs = this.get('accountConfigs')
if (accountConfigs && Object.keys(accountConfigs).length > 0) {
const safeConfigs = this.encryptAccountConfigs(accountConfigs)
this.store.set('accountConfigs', safeConfigs)
}
// 清除 auth 字段
this.store.set('authEnabled', false as any)
this.store.set('authPassword', '' as any)
this.store.set('authUseHello', false as any)
this.store.set('authHelloSecret', '' as any)
// 清除运行时状态
this.unlockedKeys.clear()
this.unlockPassword = null
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
try {
// 验证旧密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
if (storedHash !== oldHash) {
return { success: false, error: '旧密码错误' }
}
// 确保已解锁
if (this.unlockedKeys.size === 0) {
this.unlock(oldPassword)
}
// 用新密码重新加密所有密钥
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
const aiModelApiKey = this.unlockedKeys.get('aiModelApiKey')
const aiModelProfilesJson = this.unlockedKeys.get('aiModelProfilesJson')
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
if (aiModelApiKey) this.store.set('aiModelApiKey', this.lockEncrypt(String(aiModelApiKey), newPassword) as any)
if (aiModelProfilesJson) this.store.set('aiModelProfilesJson', this.lockEncrypt(String(aiModelProfilesJson), newPassword) as any)
// 重新加密 accountConfigs
const accountConfigs = this.get('accountConfigs')
if (accountConfigs && Object.keys(accountConfigs).length > 0) {
this.unlockPassword = newPassword
const lockedConfigs = this.lockEncryptAccountConfigs(accountConfigs)
this.store.set('accountConfigs', lockedConfigs)
}
// 更新密码 hash
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
// 更新 Hello secret(如果启用了 Hello)
const useHello = this.get('authUseHello')
if (useHello) {
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
}
this.unlockPassword = newPassword
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === Hello 相关 ===
setHelloSecret(password: string): void {
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
this.store.set('authUseHello', this.safeEncrypt('true') as any)
}
getHelloSecret(): string {
const raw: any = this.store.get('authHelloSecret')
if (!raw || typeof raw !== 'string') return ''
return this.safeDecrypt(raw)
}
clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any)
this.store.set('authUseHello', false as any)
}
// === 迁移 ===
private migrateAuthFields(): void {
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled')
if (rawEnabled === true || rawEnabled === 'true') {
this.store.set('authEnabled', this.safeEncrypt('true') as any)
} else if (rawEnabled === false || rawEnabled === 'false') {
// 保持 false 为明文布尔,避免冷启动访问 keychain
this.store.set('authEnabled', false as any)
}
const rawUseHello: any = this.store.get('authUseHello')
if (rawUseHello === true || rawUseHello === 'true') {
this.store.set('authUseHello', this.safeEncrypt('true') as any)
} else if (rawUseHello === false || rawUseHello === 'false') {
this.store.set('authUseHello', false as any)
}
const rawPassword: any = this.store.get('authPassword')
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
}
// 迁移敏感密钥字段(明文 → safe:)
for (const key of LOCKABLE_STRING_KEYS) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
this.store.set(key as any, this.safeEncrypt(raw) as any)
}
}
// imageXorKey: 数字 → safe:
const rawXor: any = this.store.get('imageXorKey')
if (typeof rawXor === 'number' && rawXor !== 0) {
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
}
// accountConfigs 中的嵌套密钥
const accountConfigs: any = this.store.get('accountConfigs')
if (accountConfigs && typeof accountConfigs === 'object') {
let changed = false
for (const [_accountId, cfg] of Object.entries(accountConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
changed = true
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
changed = true
}
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
changed = true
}
}
if (changed) {
this.store.set('accountConfigs', accountConfigs)
}
}
}
private migrateAiConfig(): void {
const migrationKey = 'aiModelConfigNamespaceMigrationV2'
if (this.store.get(migrationKey as any) !== true) {
// Only migrate when the shared namespace has never been explicitly stored.
// An explicitly empty shared key means the user disabled AI and must not be
// silently repopulated from retired credentials on a later startup.
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
// Conf materializes defaults into the backing store, so store.has() cannot
// distinguish an untouched default from an explicit edit. The migration
// marker is the one-time boundary; before it exists, migrate only a complete
// empty shared credential pair as one unit (never field-by-field).
const migrationPatch: Record<string, unknown> = {
aiInsightApiBaseUrl: '',
aiInsightApiKey: '',
aiInsightApiModel: '',
[migrationKey]: true
}
if (!sharedBaseUrl && !sharedApiKey && legacyBaseUrl && legacyApiKey) {
migrationPatch.aiModelApiBaseUrl = legacyBaseUrl
migrationPatch.aiModelApiKey = this.safeEncrypt(legacyApiKey)
migrationPatch.aiModelApiModel = legacyModel || 'gpt-4o-mini'
}
// One backing-store write makes the triple migration and retirement a
// single crash-safe namespace transition.
this.store.set(migrationPatch as any)
}
const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim()
if (groupSummaryFilterMode === 'blacklist') {
this.store.set('aiGroupSummaryFilterList' as any, [] as any)
this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any)
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {
// 先检查 authEnabled 字段
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
if (this.safeDecrypt(rawEnabled) === 'true') return true
}
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) return true
const rawAiModelApiKey: any = this.store.get('aiModelApiKey')
if (typeof rawAiModelApiKey === 'string' && rawAiModelApiKey.startsWith(LOCK_PREFIX)) return true
const rawProfiles: any = this.store.get('aiModelProfilesJson')
return typeof rawProfiles === 'string' && rawProfiles.startsWith(LOCK_PREFIX)
}
// === 工具方法 ===
/**
* 获取当前用户 accountId(清洗后,不带后缀)
*/
getMyAccountIdCleaned(): string {
const accountId = this.get('myAccountId')
return accountId ? this.cleanAccountDirName(accountId) : ''
}
/**
* 获取当前 accountId 对应的图片密钥,优先从 accountConfigs 中取,找不到则回退到全局配置
*/
getImageKeysForCurrentAccountId(): { xorKey: unknown; aesKey: string } {
const accountId = this.get('myAccountId')
if (accountId) {
const accountConfigs = this.get('accountConfigs')
const cfg = accountConfigs?.[accountId]
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
return {
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
}
}
}
return {
xorKey: this.get('imageXorKey'),
aesKey: this.get('imageAesKey')
}
}
/**
* 清理账号目录名称(移除后缀)
*/
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
// 移除4位后缀
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 检查是否是目录
*/
private isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* 浅层判定一个目录"看起来像不像账号目录":
* 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
*
* 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录。
*/
private accountDirLooksValid(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
/**
* 检测账号目录下是否存在 session.db。
*
* 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
* 同时兼容两种已知布局:
* - db_storage/session/session.db (嵌套布局)
* - db_storage/session.db (扁平布局)
*/
private accountDirHasSessionDb(entryPath: string): boolean {
const candidates = [
join(entryPath, 'db_storage', 'session', 'session.db'),
join(entryPath, 'db_storage', 'session.db'),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return true
}
return false
}
/**
* 获取账号目录的真实绝对路径。
*
* 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取
* 账号目录,而不要自行拼接 `join(dbPath, accountId)`。
*
* 匹配流程:
* 1. 扫描 dbPath 下所有子目录;
* 2. 同时接受**精确匹配**(`entry == cleanedAccountId`) 与
* **后缀匹配**(`entry.startsWith(cleanedAccountId + '_')`) 两种命中方式;
* 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项;
* 4. 在剩余候选中按以下优先级排序,取最优:
* - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录";
* - **后缀匹配** > 精确匹配;
* - **修改时间更新** > 更旧:兜底。
*
* @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`)
* @param accountId 账号 ID(可选,默认从配置读取 `myAccountId`)
* @returns 账号目录的完整绝对路径;找不到返回 null
*/
getAccountDir(dbPath?: string, accountId?: string): string | null {
const actualDbPath = dbPath || this.get('dbPath')
const actualAccountId = accountId || this.get('myAccountId')
if (!actualDbPath || !actualAccountId) return null
const cleanedAccountId = this.cleanAccountDirName(actualAccountId)
const normalized = actualDbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedAccountId.toLowerCase()}`
// 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
const cached = this.accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
this.accountDirCache.delete(cacheKey)
}
const lowerAccountId = cleanedAccountId.toLowerCase()
try {
const entries = readdirSync(normalized)
type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
const candidates: Candidate[] = []
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerAccountId
const isSuffixMatch = lowerEntry.startsWith(`${lowerAccountId}_`)
// 既不是精确命中、也不是前缀命中 → 与本账号 ID 无关,跳过
if (!isExactMatch && !isSuffixMatch) continue
// 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
if (!this.accountDirLooksValid(entryPath)) continue
let mtime = 0
try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
candidates.push({
entryPath,
isExact: isExactMatch,
hasSession: this.accountDirHasSessionDb(entryPath),
mtime,
})
}
if (candidates.length > 0) {
candidates.sort((a, b) => {
// 1) 优先选有 session.db 的(真实写入数据的目录)
if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
// 2) 其次优先选"带后缀"的
if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
// 3) 最后按修改时间倒序(最新的优先)
return b.mtime - a.mtime
})
const best = candidates[0].entryPath
this.accountDirCache.set(cacheKey, best)
return best
}
} catch { }
return null
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) {
return workerUserDataPath
}
return app?.getPath?.('userData') || process.cwd()
}
getCacheBasePath(): string {
return join(this.getUserDataPath(), 'cache')
}
getAll(): Partial<ConfigSchema> {
const all = { ...this.store.store } as Record<string, unknown>
if (this.cacheMapStore) {
Object.assign(all, this.cacheMapStore.entries())
}
return all as Partial<ConfigSchema>
}
clear(): void {
this.store.clear()
this.cacheMapStore?.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
}
}