From b88f4a869e41c00fd5fdb81f9eed1a6ee0fc140e Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 22 Aug 2024 16:16:47 +0800 Subject: [PATCH] wip --- src/main/event.ts | 24 ------ src/main/index.ts | 52 +------------ src/main/ipc.ts | 59 +++++++++++++++ src/main/utils/aes.ts | 24 ++++++ src/main/utils/file.ts | 55 ++++++++++++++ src/main/utils/zip.ts | 37 ++++++++++ src/main/window.ts | 3 + src/preload/index.d.ts | 7 +- src/preload/index.ts | 10 ++- src/renderer/src/i18n/index.ts | 30 ++++++-- src/renderer/src/pages/apps/App.tsx | 1 + .../src/pages/settings/GeneralSettings.tsx | 47 ++++++------ src/renderer/src/services/backup.ts | 74 +++++++++++++++++++ 13 files changed, 317 insertions(+), 106 deletions(-) delete mode 100644 src/main/event.ts create mode 100644 src/main/ipc.ts create mode 100644 src/main/utils/aes.ts create mode 100644 src/main/utils/file.ts create mode 100644 src/main/utils/zip.ts create mode 100644 src/renderer/src/services/backup.ts diff --git a/src/main/event.ts b/src/main/event.ts deleted file mode 100644 index b6b12163..00000000 --- a/src/main/event.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { dialog, SaveDialogOptions, SaveDialogReturnValue } from 'electron' -import logger from 'electron-log' -import { writeFile } from 'fs' - -export async function saveFile(_: Electron.IpcMainInvokeEvent, fileName: string, content: string): Promise { - try { - const options: SaveDialogOptions = { - title: '保存文件', - defaultPath: fileName - } - - const result: SaveDialogReturnValue = await dialog.showSaveDialog(options) - - if (!result.canceled && result.filePath) { - writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => { - if (err) { - logger.error('[IPC - Error]', 'An error occurred saving the file:', err) - } - }) - } - } catch (err) { - logger.error('[IPC - Error]', 'An error occurred saving the file:', err) - } -} diff --git a/src/main/index.ts b/src/main/index.ts index de0adf5d..daecad12 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,10 @@ import { electronApp, optimizer } from '@electron-toolkit/utils' import * as Sentry from '@sentry/electron/main' -import { app, BrowserWindow, ipcMain, session, shell } from 'electron' +import { app, BrowserWindow } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' -import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' -import { saveFile } from './event' -import AppUpdater from './updater' -import { createMainWindow, createMinappWindow } from './window' +import { registerIpc } from './ipc' +import { createMainWindow } from './window' // This method will be called when Electron has finished // initialization and is ready to create browser windows. @@ -30,49 +28,7 @@ app.whenReady().then(() => { const mainWindow = createMainWindow() - const { autoUpdater } = new AppUpdater(mainWindow) - - // IPC - ipcMain.handle('get-app-info', () => ({ - version: app.getVersion(), - isPackaged: app.isPackaged, - appPath: app.getAppPath() - })) - - ipcMain.handle('open-website', (_, url: string) => { - shell.openExternal(url) - }) - - ipcMain.handle('set-proxy', (_, proxy: string) => { - session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}) - }) - - ipcMain.handle('save-file', saveFile) - - ipcMain.handle('minapp', (_, args) => { - createMinappWindow({ - url: args.url, - windowOptions: { - ...mainWindow.getBounds(), - ...args.windowOptions - } - }) - }) - - ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => { - appConfig.set('theme', theme) - mainWindow?.setTitleBarOverlay && - mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight) - }) - - // 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法) - ipcMain.handle('check-for-update', async () => { - autoUpdater.logger?.info('触发检查更新') - return { - currentVersion: autoUpdater.currentVersion, - update: await autoUpdater.checkForUpdates() - } - }) + registerIpc(mainWindow, app) installExtension(REDUX_DEVTOOLS) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 00000000..2c3b354c --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,59 @@ +import { BrowserWindow, ipcMain, session, shell } from 'electron' + +import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' +import AppUpdater from './updater' +import { openFile, saveFile } from './utils/file' +import { compress, decompress } from './utils/zip' +import { createMinappWindow } from './window' + +export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { + const { autoUpdater } = new AppUpdater(mainWindow) + + // IPC + ipcMain.handle('get-app-info', () => ({ + version: app.getVersion(), + isPackaged: app.isPackaged, + appPath: app.getAppPath() + })) + + ipcMain.handle('open-website', (_, url: string) => { + shell.openExternal(url) + }) + + ipcMain.handle('set-proxy', (_, proxy: string) => { + session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}) + }) + + ipcMain.handle('save-file', saveFile) + ipcMain.handle('open-file', openFile) + ipcMain.handle('reload', () => mainWindow.reload()) + + ipcMain.handle('zip:compress', (_, text: string) => compress(text)) + ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) + + ipcMain.handle('minapp', (_, args) => { + createMinappWindow({ + url: args.url, + parent: mainWindow, + windowOptions: { + ...mainWindow.getBounds(), + ...args.windowOptions + } + }) + }) + + ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => { + appConfig.set('theme', theme) + mainWindow?.setTitleBarOverlay && + mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight) + }) + + // 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法) + ipcMain.handle('check-for-update', async () => { + autoUpdater.logger?.info('触发检查更新') + return { + currentVersion: autoUpdater.currentVersion, + update: await autoUpdater.checkForUpdates() + } + }) +} diff --git a/src/main/utils/aes.ts b/src/main/utils/aes.ts new file mode 100644 index 00000000..78fb07f0 --- /dev/null +++ b/src/main/utils/aes.ts @@ -0,0 +1,24 @@ +import * as crypto from 'crypto' + +// 定义密钥和初始化向量(IV) +const secretKey = 'kDQvWz5slot3syfucoo53X6KKsEUJoeFikpiUWRJTLIo3zcUPpFvEa009kK13KCr' +const iv = Buffer.from('Cherry Studio', 'hex') + +// 加密函数 +export function encrypt(text: string): { iv: string; encryptedData: string } { + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secretKey), iv) + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return { + iv: iv.toString('hex'), + encryptedData: encrypted + } +} + +// 解密函数 +export function decrypt(encryptedData: string, iv: string): string { + const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(secretKey), Buffer.from(iv, 'hex')) + let decrypted = decipher.update(encryptedData, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted +} diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts new file mode 100644 index 00000000..31691ad9 --- /dev/null +++ b/src/main/utils/file.ts @@ -0,0 +1,55 @@ +import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' +import logger from 'electron-log' +import { writeFile } from 'fs' +import { readFile } from 'fs/promises' + +export async function saveFile( + _: Electron.IpcMainInvokeEvent, + fileName: string, + content: string, + options?: SaveDialogOptions +): Promise { + try { + const result: SaveDialogReturnValue = await dialog.showSaveDialog({ + title: '保存文件', + defaultPath: fileName, + ...options + }) + + if (!result.canceled && result.filePath) { + writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => { + if (err) { + logger.error('[IPC - Error]', 'An error occurred saving the file:', err) + } + }) + } + } catch (err) { + logger.error('[IPC - Error]', 'An error occurred saving the file:', err) + } +} + +export async function openFile( + _: Electron.IpcMainInvokeEvent, + options: OpenDialogOptions +): Promise<{ fileName: string; content: Buffer } | null> { + try { + const result: OpenDialogReturnValue = await dialog.showOpenDialog({ + title: '打开文件', + properties: ['openFile'], + filters: [{ name: '所有文件', extensions: ['*'] }], + ...options + }) + + if (!result.canceled && result.filePaths.length > 0) { + const filePath = result.filePaths[0] + const fileName = filePath.split('/').pop() || '' + const content = await readFile(filePath) + return { fileName, content } + } + + return null + } catch (err) { + logger.error('[IPC - Error]', 'An error occurred opening the file:', err) + return null + } +} diff --git a/src/main/utils/zip.ts b/src/main/utils/zip.ts new file mode 100644 index 00000000..d265bba6 --- /dev/null +++ b/src/main/utils/zip.ts @@ -0,0 +1,37 @@ +import util from 'node:util' +import zlib from 'node:zlib' + +// 将 zlib 的 gzip 和 gunzip 方法转换为 Promise 版本 +const gzipPromise = util.promisify(zlib.gzip) +const gunzipPromise = util.promisify(zlib.gunzip) + +/** + * 压缩字符串 + * @param {string} string - 要压缩的 JSON 字符串 + * @returns {Promise} 压缩后的 Buffer + */ +export async function compress(str) { + try { + const buffer = Buffer.from(str, 'utf-8') + const compressedBuffer = await gzipPromise(buffer) + return compressedBuffer + } catch (error) { + console.error('Compression failed:', error) + throw error + } +} + +/** + * 解压缩 Buffer 到 JSON 字符串 + * @param {Buffer} compressedBuffer - 压缩的 Buffer + * @returns {Promise} 解压缩后的 JSON 字符串 + */ +export async function decompress(compressedBuffer) { + try { + const buffer = await gunzipPromise(compressedBuffer) + return buffer.toString('utf-8') + } catch (error) { + console.error('Decompression failed:', error) + throw error + } +} diff --git a/src/main/window.ts b/src/main/window.ts index cf6fefb3..9579763d 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -94,9 +94,11 @@ export function createMainWindow() { export function createMinappWindow({ url, + parent, windowOptions }: { url: string + parent?: BrowserWindow windowOptions?: Electron.BrowserWindowConstructorOptions }) { const width = windowOptions?.width || 1000 @@ -108,6 +110,7 @@ export function createMinappWindow({ autoHideMenuBar: true, title: 'Cherry Studio', ...windowOptions, + parent, webPreferences: { preload: join(__dirname, '../preload/minapp.js'), sandbox: false, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 91c11553..c9d026c8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,4 +1,5 @@ import { ElectronAPI } from '@electron-toolkit/preload' +import type { OpenDialogOptions } from 'electron' declare global { interface Window { @@ -12,9 +13,13 @@ declare global { checkForUpdate: () => void openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void - saveFile: (path: string, content: string) => void + saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void + openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null> setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void + reload: () => void + compress: (text: string) => Promise + decompress: (text: Buffer) => Promise } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 885fb4e3..3a5db511 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,9 +7,15 @@ const api = { checkForUpdate: () => ipcRenderer.invoke('check-for-update'), openWebsite: (url: string) => ipcRenderer.invoke('open-website', url), setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy), - saveFile: (path: string, content: string) => ipcRenderer.invoke('save-file', path, content), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme), - minApp: (url: string) => ipcRenderer.invoke('minapp', url) + minApp: (url: string) => ipcRenderer.invoke('minapp', url), + openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options), + reload: () => ipcRenderer.invoke('reload'), + saveFile: (path: string, content: string, options?: { compress: boolean }) => { + ipcRenderer.invoke('save-file', path, content, options) + }, + compress: (text: string) => ipcRenderer.invoke('zip:compress', text), + decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index afd3abfb..a075a775 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -28,7 +28,8 @@ const resources = { footnotes: 'References', select: 'Select', search: 'Search', - default: 'Default' + default: 'Default', + warning: 'Warning' }, button: { add: 'Add', @@ -48,7 +49,11 @@ const resources = { 'api.connection.failed': 'Connection failed', 'api.connection.success': 'Connection successful', 'chat.completion.paused': 'Chat completion paused', - 'switch.disabled': 'Switching is disabled while the assistant is generating' + 'switch.disabled': 'Switching is disabled while the assistant is generating', + 'restore.success': 'Restored successfully', + 'reset.confirm.content': 'Are you sure you want to clear all data?', + 'reset.double.confirm.title': 'DATA LOST !!!', + 'reset.double.confirm.content': 'All data will be lost, do you want to continue?' }, chat: { save: 'Save', @@ -141,6 +146,9 @@ const resources = { 'general.title': 'General Settings', 'general.user_name': 'User Name', 'general.user_name.placeholder': 'Enter your name', + 'general.backup.title': 'Data Backup and Recovery', + 'general.reset.title': 'Data Reset', + 'general.reset.button': 'Reset', 'provider.api_key': 'API Key', 'provider.check': 'Check', 'provider.get_api_key': 'Get API Key', @@ -220,7 +228,8 @@ const resources = { 'keep_alive_time.description': 'The time in minutes to keep the connection alive, default is 5 minutes.' }, error: { - 'chat.response': 'Something went wrong. Please check if you have set your API key in the Settings > Providers' + 'chat.response': 'Something went wrong. Please check if you have set your API key in the Settings > Providers', + 'backup.file_format': 'Backup file format error' }, words: { knowledgeGraph: 'Knowledge Graph', @@ -253,7 +262,8 @@ const resources = { footnote: '引用内容', select: '选择', search: '搜索', - default: '默认' + default: '默认', + warning: '警告' }, button: { add: '添加', @@ -273,7 +283,11 @@ const resources = { 'api.connection.failed': '连接失败', 'api.connection.success': '连接成功', 'chat.completion.paused': '会话已停止', - 'switch.disabled': '模型回复完成后才能切换' + 'switch.disabled': '模型回复完成后才能切换', + 'restore.success': '恢复成功', + 'reset.confirm.content': '确定要重置所有数据吗?', + 'reset.double.confirm.title': '数据丢失!!!', + 'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?' }, chat: { save: '保存', @@ -367,6 +381,9 @@ const resources = { 'general.title': '常规设置', 'general.user_name': '用户名', 'general.user_name.placeholder': '请输入用户名', + 'general.backup.title': '数据备份与恢复', + 'general.reset.title': '重置数据', + 'general.reset.button': '重置', 'provider.api_key': 'API 密钥', 'provider.check': '检查', 'provider.get_api_key': '点击这里获取密钥', @@ -446,7 +463,8 @@ const resources = { 'keep_alive_time.description': '对话后模型在内存中保持的时间(默认:5分钟)' }, error: { - 'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥' + 'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥', + 'backup.file_format': '备份文件格式错误' }, words: { knowledgeGraph: '知识图谱', diff --git a/src/renderer/src/pages/apps/App.tsx b/src/renderer/src/pages/apps/App.tsx index d3e85120..08e4118b 100644 --- a/src/renderer/src/pages/apps/App.tsx +++ b/src/renderer/src/pages/apps/App.tsx @@ -51,6 +51,7 @@ const AppTitle = styled.div` margin-top: 5px; color: var(--color-text-soft); text-align: center; + user-select: none; ` export default App diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 77f7bd83..d15fddb1 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -1,13 +1,15 @@ +import { HStack } from '@renderer/components/Layout' import useAvatar from '@renderer/hooks/useAvatar' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' +import { backup, reset, restore } from '@renderer/services/backup' import LocalStorage from '@renderer/services/storage' import { useAppDispatch } from '@renderer/store' import { setAvatar } from '@renderer/store/runtime' -import { setFontSize, setLanguage, setUserName, ThemeMode } from '@renderer/store/settings' +import { setLanguage, setUserName, ThemeMode } from '@renderer/store/settings' import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' import { compressImage, isValidProxyUrl } from '@renderer/utils' -import { Avatar, Input, Select, Slider, Upload } from 'antd' +import { Avatar, Button, Input, Select, Upload } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -16,8 +18,7 @@ import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingT const GeneralSettings: FC = () => { const avatar = useAvatar() - const { language, proxyUrl: storeProxyUrl, userName, theme, setTheme, fontSize } = useSettings() - const [fontSizeValue, setFontSizeValue] = useState(fontSize) + const { language, proxyUrl: storeProxyUrl, userName, theme, setTheme } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const dispatch = useAppDispatch() const { t } = useTranslation() @@ -101,27 +102,6 @@ const GeneralSettings: FC = () => { /> - - {t('settings.font_size.title')} - setFontSizeValue(value)} - onChangeComplete={(value) => { - dispatch(setFontSize(value)) - console.debug('set font size', value) - }} - min={12} - max={18} - step={1} - marks={{ - 12: A, - 14: {t('common.default')}, - 18: A - }} - /> - - {t('settings.proxy.title')} { /> + + {t('settings.general.backup.title')} + + + + + + + + {t('settings.general.reset.title')} + + + + + ) } diff --git a/src/renderer/src/services/backup.ts b/src/renderer/src/services/backup.ts new file mode 100644 index 00000000..ee02a3b2 --- /dev/null +++ b/src/renderer/src/services/backup.ts @@ -0,0 +1,74 @@ +import i18n from '@renderer/i18n' +import dayjs from 'dayjs' +import localforage from 'localforage' + +export async function backup() { + const indexedKeys = await localforage.keys() + const version = 1 + const time = new Date().getTime() + + const data = { + time, + version, + localStorage, + indexedDB: [] as { key: string; value: any }[] + } + + for (const key of indexedKeys) { + data.indexedDB.push({ + key, + value: await localforage.getItem(key) + }) + } + + const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak` + const fileContnet = JSON.stringify(data) + const file = await window.api.compress(fileContnet) + + window.api.saveFile(filename, file) +} + +export async function restore() { + const file = await window.api.openFile() + + if (file) { + try { + const content = await window.api.decompress(file.content) + const data = JSON.parse(content) + + if (data.version === 1) { + localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) + + for (const { key, value } of data.indexedDB) { + await localforage.setItem(key, value) + } + + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) + setTimeout(() => window.api.reload(), 1500) + } else { + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + } + } catch (error) { + console.error(error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + } + } +} + +export async function reset() { + window.modal.confirm({ + title: i18n.t('common.warning'), + content: i18n.t('message.reset.confirm.content'), + onOk: async () => { + window.modal.confirm({ + title: i18n.t('message.reset.double.confirm.title'), + content: i18n.t('message.reset.double.confirm.content'), + onOk: async () => { + await localStorage.clear() + await localforage.clear() + window.api.reload() + } + }) + } + }) +}