mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-10-25 17:36:32 +08:00
1207 lines
46 KiB
TypeScript
1207 lines
46 KiB
TypeScript
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
|
||
}
|
||
}
|
||
|