Skip to content

Commit eb973af

Browse files
committed
feat: add configuration import/export functionality with password protection and enhance model management UI
1 parent b2cca7f commit eb973af

16 files changed

Lines changed: 1734 additions & 350 deletions

main/helpers/configExporter.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs';
3+
import { dialog } from 'electron';
4+
import { store } from './store';
5+
import { logMessage } from './logger';
6+
7+
const MAGIC = 'VSM_CONFIG';
8+
const FORMAT_VERSION = 1;
9+
const PBKDF2_ITERATIONS = 100000;
10+
const SALT_LENGTH = 16;
11+
const IV_LENGTH = 12;
12+
const KEY_LENGTH = 32;
13+
14+
interface EncryptedPayload {
15+
magic: string;
16+
version: number;
17+
salt: string;
18+
iv: string;
19+
authTag: string;
20+
data: string;
21+
}
22+
23+
function deriveKey(password: string, salt: Buffer): Buffer {
24+
return crypto.pbkdf2Sync(
25+
password,
26+
salt,
27+
PBKDF2_ITERATIONS,
28+
KEY_LENGTH,
29+
'sha512',
30+
);
31+
}
32+
33+
function encryptData(jsonString: string, password: string): EncryptedPayload {
34+
const salt = crypto.randomBytes(SALT_LENGTH);
35+
const iv = crypto.randomBytes(IV_LENGTH);
36+
const key = deriveKey(password, salt);
37+
38+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
39+
const encrypted = Buffer.concat([
40+
cipher.update(jsonString, 'utf8'),
41+
cipher.final(),
42+
]);
43+
const authTag = cipher.getAuthTag();
44+
45+
return {
46+
magic: MAGIC,
47+
version: FORMAT_VERSION,
48+
salt: salt.toString('base64'),
49+
iv: iv.toString('base64'),
50+
authTag: authTag.toString('base64'),
51+
data: encrypted.toString('base64'),
52+
};
53+
}
54+
55+
function decryptData(payload: EncryptedPayload, password: string): string {
56+
const salt = Buffer.from(payload.salt, 'base64');
57+
const iv = Buffer.from(payload.iv, 'base64');
58+
const authTag = Buffer.from(payload.authTag, 'base64');
59+
const encrypted = Buffer.from(payload.data, 'base64');
60+
const key = deriveKey(password, salt);
61+
62+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
63+
decipher.setAuthTag(authTag);
64+
const decrypted = Buffer.concat([
65+
decipher.update(encrypted),
66+
decipher.final(),
67+
]);
68+
69+
return decrypted.toString('utf8');
70+
}
71+
72+
function collectExportData(): Record<string, any> {
73+
const settings = store.get('settings');
74+
const {
75+
modelsPath: _modelsPath,
76+
customTempDir: _customTempDir,
77+
...portableSettings
78+
} = settings;
79+
80+
return {
81+
translationProviders: store.get('translationProviders') || [],
82+
userConfig: store.get('userConfig') || {},
83+
settings: portableSettings,
84+
customParameters: store.get('customParameters') || {},
85+
};
86+
}
87+
88+
function validateImportData(data: any): boolean {
89+
if (!data || typeof data !== 'object') return false;
90+
if (!Array.isArray(data.translationProviders)) return false;
91+
if (!data.userConfig || typeof data.userConfig !== 'object') return false;
92+
if (!data.settings || typeof data.settings !== 'object') return false;
93+
return true;
94+
}
95+
96+
export async function exportConfig(
97+
password: string,
98+
): Promise<{ success: boolean; error?: string }> {
99+
try {
100+
const data = collectExportData();
101+
const jsonString = JSON.stringify(data);
102+
const encrypted = encryptData(jsonString, password);
103+
104+
const result = await dialog.showSaveDialog({
105+
title: 'Export Configuration',
106+
defaultPath: `smartsub-config-${new Date().toISOString().slice(0, 10)}.vsm`,
107+
filters: [{ name: 'SmartSub Config', extensions: ['vsm'] }],
108+
});
109+
110+
if (result.canceled || !result.filePath) {
111+
return { success: false, error: 'canceled' };
112+
}
113+
114+
await fs.promises.writeFile(
115+
result.filePath,
116+
JSON.stringify(encrypted, null, 2),
117+
'utf-8',
118+
);
119+
120+
logMessage(`Config exported to ${result.filePath}`, 'info');
121+
return { success: true };
122+
} catch (error) {
123+
logMessage(`Config export failed: ${error.message}`, 'error');
124+
return { success: false, error: error.message };
125+
}
126+
}
127+
128+
export async function importConfig(
129+
password: string,
130+
): Promise<{ success: boolean; error?: string }> {
131+
try {
132+
const result = await dialog.showOpenDialog({
133+
title: 'Import Configuration',
134+
filters: [{ name: 'SmartSub Config', extensions: ['vsm'] }],
135+
properties: ['openFile'],
136+
});
137+
138+
if (result.canceled || result.filePaths.length === 0) {
139+
return { success: false, error: 'canceled' };
140+
}
141+
142+
const fileContent = await fs.promises.readFile(
143+
result.filePaths[0],
144+
'utf-8',
145+
);
146+
let payload: EncryptedPayload;
147+
148+
try {
149+
payload = JSON.parse(fileContent);
150+
} catch {
151+
return { success: false, error: 'invalidConfigFile' };
152+
}
153+
154+
if (
155+
payload.magic !== MAGIC ||
156+
!payload.salt ||
157+
!payload.iv ||
158+
!payload.authTag ||
159+
!payload.data
160+
) {
161+
return { success: false, error: 'invalidConfigFile' };
162+
}
163+
164+
let jsonString: string;
165+
try {
166+
jsonString = decryptData(payload, password);
167+
} catch {
168+
return { success: false, error: 'invalidPassword' };
169+
}
170+
171+
let data: any;
172+
try {
173+
data = JSON.parse(jsonString);
174+
} catch {
175+
return { success: false, error: 'invalidConfigFile' };
176+
}
177+
178+
if (!validateImportData(data)) {
179+
return { success: false, error: 'invalidConfigFile' };
180+
}
181+
182+
const currentSettings = store.get('settings');
183+
store.set('translationProviders', data.translationProviders);
184+
store.set('userConfig', data.userConfig);
185+
store.set('settings', {
186+
...currentSettings,
187+
...data.settings,
188+
modelsPath: currentSettings.modelsPath,
189+
customTempDir: currentSettings.customTempDir,
190+
});
191+
if (data.customParameters) {
192+
store.set('customParameters', data.customParameters);
193+
}
194+
195+
logMessage(`Config imported from ${result.filePaths[0]}`, 'info');
196+
return { success: true };
197+
} catch (error) {
198+
logMessage(`Config import failed: ${error.message}`, 'error');
199+
return { success: false, error: error.message };
200+
}
201+
}

main/helpers/ipcStoreHandlers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getAndInitializeProviders } from './providerManager';
66
import { logMessage } from './logger';
77
import { LogEntry } from './store/types';
88
import { getBuildInfo } from './buildInfo';
9+
import { exportConfig, importConfig } from './configExporter';
910

1011
console.log(app.getVersion(), 'version');
1112
export function setupStoreHandlers() {
@@ -84,4 +85,13 @@ export function setupStoreHandlers() {
8485
store.clear();
8586
return true;
8687
});
88+
89+
// 配置导入导出
90+
ipcMain.handle('exportConfig', async (_event, password: string) => {
91+
return exportConfig(password);
92+
});
93+
94+
ipcMain.handle('importConfig', async (_event, password: string) => {
95+
return importConfig(password);
96+
});
8797
}

0 commit comments

Comments
 (0)