桌面端验配/业务软件常需要在本机保存结构化数据(客户、业务记录等),同时希望:
.json,降低用户误开、误传、误改的概率get / set,而不是手写大量 fs.readFile / JSON.parse默认的 electron-store 会把明文 JSON 写到 userData 下的 .json 文件里,不满足 1、2。好在它支持:
fileExtension:自定义后缀serialize / deserialize:自定义序列化管线cwd:自定义落盘目录这两条钩子就是整套方案的核心。
复制代码安装目录/
├── YourApp.exe
└── data/
├── app-records.dat ← 加密后的 hex 文本,扩展名自定(这里假设为dat)
└── app-entities.dat
用记事本打开 .dat 只会看到一长串十六进制字符,而不是明文 JSON。
复制代码┌─────────────────┐ IPC ┌──────────────────┐
│ Renderer (Vue) │ ──────────► │ Main Process │
│ 只调业务 API │ │ entity-store.ts │
└─────────────────┘ │ │ │
│ ▼ │
│ createEncryptedStore()
│ serialize → AES → hex
│ deserialize ← 解密
│ │ │
│ ▼ │
│ data/*.dat 文件 │
└──────────────────┘
分层建议:
| 层 | 职责 |
|---|---|
encrypted-store 工具 | 加解密 + 创建加密 Store |
xxx-store 业务模块 | Schema、CRUD、软删除等 |
xxx-ipc | 暴露给渲染进程的通道 |
| (可选)backup / import | 复用同一套加解密做备份包 |
在 Electron 主进程侧使用(不要放进渲染进程打包依赖的滥用路径):
复制代码npm install electron-store crypto-js
npm install -D @types/crypto-js # 若使用 TypeScript
说明:
electron-store:键值存取、原子写入、默认值crypto-js:在主进程做 AES;也可换成 Node crypto,思路相同业务数据若希望「随安装目录走、方便拷贝/备份」,不要只用默认 app.getPath('userData')。可按打包态分支:
复制代码// electron/utils/app-paths.ts(示意)
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { app } from 'electron'/** 打包后:安装目录;开发时:项目根 */
export function resolveAppInstallDir(): string {
return app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(process.env.APP_ROOT ?? process.cwd())
}/** 确保 <install>/data 存在并返回 */
export function resolveAppDataDir(): string {
const dir = path.join(resolveAppInstallDir(), 'data')
mkdirSync(dir, { recursive: true })
return dir
}
这样开发时数据在仓库旁的 data/,发布后在 .exe 同级的 data/,运维定位文件更直观。
设计目标:
JSON.stringify → AES 加密 → Hex 文本 写入文件JSON.parseHex 的好处:文件全程是可打印 ASCII,复制、日志截断、跨平台换行更稳;代价是体积大约翻倍,对本机业务库通常可接受。
复制代码// electron/utils/encrypted-store.ts(示意,密钥请勿写死在仓库)
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import path from 'node:path'import CryptoJS from 'crypto-js'
import Store, { type Options as StoreOptions } from 'electron-store'import { resolveAppDataDir } from './app-paths'/**
* 密钥读取示意:生产环境应来自安全配置 / 构建注入 / 系统凭据,
* 切勿把真实密钥提交到 Git。
*/
function getStoreSecret(): string {
const secret = process.env.APP_STORE_SECRET
if (!secret) {
throw new Error('缺少 APP_STORE_SECRET')
}
return secret
}/** 明文 → AES → Base64 密文 → 再编码为 Hex 字符串落盘 */
function encryptToHex(plainText: string): string {
const encrypted = CryptoJS.AES.encrypt(plainText, getStoreSecret()).toString()
return CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(encrypted))
}/** Hex 文件内容 → 解密得到明文 JSON 字符串 */
function decryptFromHex(hex: string): string {
const encrypted = CryptoJS.enc.Hex.parse(hex).toString(CryptoJS.enc.Utf8)
const decrypted = CryptoJS.AES.decrypt(encrypted, getStoreSecret())
const result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) {
throw new Error('解密 store 数据失败(密钥错误或文件损坏)')
}
return result
}/** 供备份导出等「不经过 Store」场景复用同一算法 */
export function encryptStoreContent(plainText: string): string {
return encryptToHex(plainText)
}export function decryptStoreContent(hex: string): string {
return decryptFromHex(hex)
}
createEncryptedStore把自定义扩展名、目录、序列化一次性绑死,业务侧只传 name 和 defaults:
复制代码const CUSTOM_EXT = 'dat' // 示例后缀;本项目可使用自有扩展名/** 旧版明文 .json → 新版加密文件(一次性迁移) */
function migrateLegacyJsonStore(dataDir: string, name: string): void {
const encryptedPath = path.join(dataDir, `${name}.${CUSTOM_EXT}`)
const jsonPath = path.join(dataDir, `${name}.json`)
if (existsSync(encryptedPath) || !existsSync(jsonPath)) return const raw = readFileSync(jsonPath, 'utf8')
writeFileSync(encryptedPath, encryptToHex(raw), 'utf8')
unlinkSync(jsonPath)
}/** 创建主进程专用加密 Store */
export function createEncryptedStore<T extends Record<string, unknown>>(
options: Omit<StoreOptions<T>, 'serialize' | 'deserialize' | 'fileExtension' | 'cwd'>
): Store<T> {
const dataDir = resolveAppDataDir()
const name = options.name ?? 'config'
migrateLegacyJsonStore(dataDir, name) return new Store<T>({
...options,
cwd: dataDir,
fileExtension: CUSTOM_EXT,
serialize: value => encryptToHex(JSON.stringify(value)),
deserialize: text => JSON.parse(decryptFromHex(text)) as T,
})
}
关键点:
| 选项 | 作用 |
|---|---|
cwd | 落到 data/,而不是默认 userData |
fileExtension | 例如 dat / 项目自定义后缀 |
serialize | 写盘前加密 |
deserialize | 读盘后解密 |
| 迁移函数 | 老用户仍有明文 .json 时平滑升级 |
业务代码从此禁止再绕过这层直接读文件,否则加解密格式容易分裂。
以「实体列表」为例(字段已抽象):
复制代码// electron/hooks/entity-store.ts(示意)
import { randomUUID } from 'node:crypto'
import { createEncryptedStore } from '../utils/encrypted-store'interface EntityRecord {
id: string
title: string
updatedAt: string
deletedAt?: string | null
}interface EntityStoreSchema {
entities: EntityRecord[]
}const store = createEncryptedStore<EntityStoreSchema>({
name: 'app-entities',
defaults: {
entities: [],
},
})export function listEntities(): EntityRecord[] {
return store
.get('entities')
.filter(item => !item.deletedAt)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}export function addEntity(title: string): EntityRecord {
const now = new Date().toISOString()
const record: EntityRecord = {
id: randomUUID(),
title,
updatedAt: now,
}
const entities = store.get('entities')
store.set('entities', [record, ...entities])
return record
}/** 软删除:保留痕迹便于审计 / 导入合并 */
export function softDeleteEntity(id: string): void {
const entities = store.get('entities')
const index = entities.findIndex(item => item.id === id)
if (index < 0) throw new Error('记录不存在') const next = [...entities]
next[index] = {
...entities[index],
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
store.set('entities', next)
}
实践建议:
Store<EntityStoreSchema>,避免 any 蔓延set,勿原地 mutate 后期望自动落盘落盘结果类似:
复制代码data/app-entities.dat # 一整文件都是 hex
加密文件应仅主进程接触。渲染层只拿「业务结果」:
复制代码// electron/ipc/entity-ipc.ts(示意)
import { ipcMain } from 'electron'
import { addEntity, listEntities, softDeleteEntity } from '../hooks/entity-store'export function registerEntityIpc() {
ipcMain.handle('entity:list', () => listEntities())
ipcMain.handle('entity:add', (_e, title: string) => addEntity(title))
ipcMain.handle('entity:delete', (_e, id: string) => softDeleteEntity(id))
}
渲染进程:
复制代码// 渲染侧示意
const list = await window.api.invoke('entity:list')
配合 contextIsolation + preload 白名单通道,避免渲染进程直接 require('fs') 或拿到 Store 实例。
有了统一的 encryptStoreContent / decryptStoreContent,备份包可以做成「外层再包一层加密」的结构:
复制代码// 备份包结构示意(明文逻辑;落盘前整体再加密)
interface DataBackupBundle {
version: 1
exportedAt: string
files: Record<string, string> // key: 文件名,value: 单库已加密的 hex
}function writeBackup(targetDir: string, bundle: DataBackupBundle): string {
const fileName = `backup_${Date.now()}_all.dat`
const outputPath = path.join(targetDir, fileName)
// 注意:bundle.files 里已经是各 store 的密文;整包 JSON 再加密一层
writeFileSync(outputPath, encryptStoreContent(JSON.stringify(bundle)), 'utf8')
return outputPath
}function readBackup(filePath: string): DataBackupBundle {
const raw = readFileSync(filePath, 'utf8')
const bundle = JSON.parse(decryptStoreContent(raw)) as DataBackupBundle
if (!bundle?.files || typeof bundle.files !== 'object') {
throw new Error('备份文件格式无效')
}
return bundle
}
导入时:
createEncryptedStore 的序列化)这样运行时库、导出包、导入包三套文件格式一致,维护成本最低。
可按下面顺序一次做完:
electron-store、crypto-jsresolveAppDataDir(),确认打包后目录正确encryptToHex / decryptFromHex,密钥走环境变量或安全配置createEncryptedStore(cwd + 自定义扩展名 + serialize/deserialize).json → 加密文件,只跑一次xxx-store:defaults + CRUD + 类型 SchemaencryptStoreContentget/set 往返数据一致验证片段:
复制代码const store = createEncryptedStore<{ count: number }>({
name: 'smoke-test',
defaults: { count: 0 },
})
store.set('count', 42)
console.log(store.get('count')) // 42
// 打开 data/smoke-test.dat,应看不到明文 42
在渲染进程使用 electron-store
安全隔离与打包路径都更乱;统一放主进程。
忘记 JSON.stringify 包在加密外层
serialize 收到的是对象,必须先序列化再加密。
Hex / Base64 搞混
加密后 CryptoJS 默认是 Base64 字符串,本方案再转 Hex 落盘;解密必须严格逆序。
密钥更换
一旦更换密钥,旧文件全部不可读。需要版本字段或迁移工具,上线前要计划好。
大对象频繁整文件 set
electron-store 适合中小规模本机库;数据量很大时再考虑 SQLite 等。
把真实密钥写进示例博客或仓库
本项目生产密钥应独立管理;文档与示例一律用占位符。
electron-store 本身不是加密方案,但它把「改序列化管线」开放了出来。通过:
fileExtension → 变成业务自有格式文件serialize / deserialize + AES → 磁盘上不是明文 JSONcwd 指向安装目录 data/ → 文件位置可控就能在不明显牺牲开发体验的前提下,完成一套适合桌面业务软件的本地加密存储。
