diff --git a/src/main/config.ts b/src/main/config.ts index 3072b4bcd9..bc7daa8f82 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -4,6 +4,10 @@ import { app } from 'electron' import Store from 'electron-store' import path from 'path' +const isDev = process.env.NODE_ENV === 'development' + +isDev && app.setPath('userData', app.getPath('userData') + 'Dev') + const getDataPath = () => { const dataPath = path.join(app.getPath('userData'), 'Data') if (!fs.existsSync(dataPath)) { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b3e64634a6..6f39d33317 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -56,10 +56,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options)) ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file)) + ipcMain.handle('file:clear', async () => await fileManager.clear()) ipcMain.handle('file:delete', async (_, fileId: string) => { await fileManager.deleteFile(fileId) return { success: true } }) + ipcMain.handle('minapp', (_, args) => { createMinappWindow({ url: args.url, diff --git a/src/main/services/File.ts b/src/main/services/File.ts index 8cb3b09020..0f551d5305 100644 --- a/src/main/services/File.ts +++ b/src/main/services/File.ts @@ -134,6 +134,11 @@ class File { async deleteFile(id: string): Promise { await fs.promises.unlink(path.join(this.storageDir, id)) } + + async clear(): Promise { + await fs.promises.rmdir(this.storageDir, { recursive: true }) + await this.initStorageDir() + } } export default File diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index dcd94bf18e..ab979a903d 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,6 +1,6 @@ import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' import logger from 'electron-log' -import { writeFile } from 'fs' +import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' import { FileTypes } from '../../renderer/src/types' @@ -19,11 +19,7 @@ export async function saveFile( }) 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) - } - }) + await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) } } catch (err) { logger.error('[IPC - Error]', 'An error occurred saving the file:', err) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 794aae040d..3e5b1123d6 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -25,6 +25,7 @@ declare global { select: (options?: OpenDialogOptions) => Promise upload: (file: FileType) => Promise delete: (fileId: string) => Promise<{ success: boolean }> + clear: () => Promise } image: { base64: (filePath: string) => Promise<{ mime: string; base64: string; data: string }> diff --git a/src/preload/index.ts b/src/preload/index.ts index c4cb0a7643..f7ed8ee82c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,14 +12,15 @@ const api = { 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) + return ipcRenderer.invoke('save-file', path, content, options) }, compress: (text: string) => ipcRenderer.invoke('zip:compress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text), file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath), - delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId) + delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId), + clear: () => ipcRenderer.invoke('file:clear') }, image: { base64: (filePath: string) => ipcRenderer.invoke('image:base64', filePath) diff --git a/src/renderer/src/databases/populate.ts b/src/renderer/src/databases/populate.ts index 3f04165412..c85d031db0 100644 --- a/src/renderer/src/databases/populate.ts +++ b/src/renderer/src/databases/populate.ts @@ -4,6 +4,11 @@ import localforage from 'localforage' export async function populateTopics(trans: Transaction) { const indexedKeys = await localforage.keys() + + if (indexedKeys.length === 0) { + return + } + for (const key of indexedKeys) { const value: any = await localforage.getItem(key) if (key.startsWith('topic:')) { diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 56fd106ab1..7c5eb3e7dd 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -55,6 +55,7 @@ const resources = { 'chat.completion.paused': 'Chat completion paused', 'switch.disabled': 'Switching is disabled while the assistant is generating', 'restore.success': 'Restored successfully', + 'backup.success': 'Backup successful', '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?', @@ -321,6 +322,7 @@ const resources = { 'chat.completion.paused': '会话已停止', 'switch.disabled': '模型回复完成后才能切换', 'restore.success': '恢复成功', + 'backup.success': '备份成功', 'reset.confirm.content': '确定要重置所有数据吗?', 'reset.double.confirm.title': '数据丢失!!!', 'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?', diff --git a/src/renderer/src/services/backup.ts b/src/renderer/src/services/backup.ts index b16f77b3d8..63d33fa3fd 100644 --- a/src/renderer/src/services/backup.ts +++ b/src/renderer/src/services/backup.ts @@ -1,31 +1,26 @@ +import db from '@renderer/databases' 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 version = 2 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) - }) + indexedDB: await backupDatabase() } 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) + await window.api.saveFile(filename, file) + + window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) } export async function restore() { @@ -37,17 +32,19 @@ export async function restore() { 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' }) + window.modal.confirm({ content: 'Please use a version less than v0.7.0 for recovery.' }) + return } + + if (data.version === 2) { + localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) + await restoreDatabase(data.indexedDB) + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) + setTimeout(() => window.api.reload(), 1000) + return + } + + 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' }) @@ -59,6 +56,7 @@ export async function reset() { window.modal.confirm({ title: i18n.t('common.warning'), content: i18n.t('message.reset.confirm.content'), + centered: true, onOk: async () => { window.modal.confirm({ title: i18n.t('message.reset.double.confirm.title'), @@ -67,9 +65,43 @@ export async function reset() { onOk: async () => { await localStorage.clear() await localforage.clear() + await clearDatabase() + await window.api.file.clear() window.api.reload() } }) } }) } + +/************************************* Backup Utils ************************************** */ + +async function backupDatabase() { + const tables = db.tables + const backup = {} + + for (const table of tables) { + backup[table.name] = await table.toArray() + } + + return backup +} + +async function restoreDatabase(backup: Record) { + await db.transaction('rw', db.tables, async () => { + for (const tableName in backup) { + await db.table(tableName).clear() + await db.table(tableName).bulkAdd(backup[tableName]) + } + }) +} + +async function clearDatabase() { + const storeNames = await db.tables.map((table) => table.name) + + await db.transaction('rw', db.tables, async () => { + for (const storeName of storeNames) { + await db[storeName].clear() + } + }) +}