diff --git a/main.js b/main.js index d88bb5d8..f989cee8 100644 --- a/main.js +++ b/main.js @@ -19,6 +19,7 @@ const log = require("electron-log/main"); const os = require("os"); const store = new Store(); const fs = require("fs"); +const JSZip = require("jszip"); const configDir = app.getPath("userData"); const dirPath = path.join(configDir, "uploads"); const packageJson = require("./package.json"); @@ -198,6 +199,116 @@ const removePickerUtil = (config) => { pickerUtilCache[config.service] = null; } }; +const addDirectoryToZip = async (zip, sourceDir, zipDir) => { + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + if (entries.length === 0) { + zip.file(zipDir, null, { dir: true, createFolders: true }); + return; + } + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const zipPath = path.posix.join(zipDir, entry.name); + + if (entry.isDirectory()) { + await addDirectoryToZip(zip, sourcePath, zipPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const stats = await fs.promises.stat(sourcePath); + zip.file(zipPath, fs.createReadStream(sourcePath), { + binary: true, + createFolders: true, + date: stats.mtime, + }); + } +}; +const createBackupArchive = async (config) => { + const { dataPath, targetPath, fileName, databaseList = [] } = config; + const destinationPath = path.join(targetPath, fileName); + const tempPath = destinationPath + ".tmp"; + const zip = new JSZip(); + + await fs.promises.mkdir(targetPath, { recursive: true }); + + if (fs.existsSync(tempPath)) { + await fs.promises.unlink(tempPath); + } + + const directories = [ + { source: path.join(dataPath, "book"), target: "book" }, + { source: path.join(dataPath, "cover"), target: "cover" }, + ]; + for (const directory of directories) { + if (fs.existsSync(directory.source)) { + await addDirectoryToZip(zip, directory.source, directory.target); + } + } + + const configFiles = ["config.json", "sync.json"]; + for (const configFile of configFiles) { + const sourcePath = path.join(dataPath, "config", configFile); + if (!fs.existsSync(sourcePath)) { + continue; + } + const stats = await fs.promises.stat(sourcePath); + zip.file(path.posix.join("config", configFile), fs.createReadStream(sourcePath), { + binary: true, + createFolders: true, + date: stats.mtime, + }); + } + + for (const dbName of databaseList) { + const sourcePath = path.join(dataPath, "config", `${dbName}.db`); + if (!fs.existsSync(sourcePath)) { + continue; + } + const stats = await fs.promises.stat(sourcePath); + zip.file(path.posix.join("config", `${dbName}.db`), fs.createReadStream(sourcePath), { + binary: true, + createFolders: true, + date: stats.mtime, + }); + } + + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(tempPath); + const stream = zip.generateNodeStream({ + type: "nodebuffer", + streamFiles: true, + compression: "DEFLATE", + compressionOptions: { level: 6 }, + }); + + const handleError = async (error) => { + output.destroy(); + if (fs.existsSync(tempPath)) { + try { + await fs.promises.unlink(tempPath); + } catch (_) {} + } + reject(error); + }; + + output.on("close", resolve); + output.on("error", handleError); + stream.on("error", handleError); + stream.pipe(output); + }); + + if (fs.existsSync(destinationPath)) { + await fs.promises.unlink(destinationPath); + } + + await fs.promises.rename(tempPath, destinationPath); + return destinationPath; +}; // Simple encryption function const encrypt = (text, key) => { let result = ""; @@ -696,6 +807,18 @@ const createMainWin = () => { }); return result.filePaths[0]; }); + ipcMain.handle("stream-backup-zip", async (event, config) => { + try { + await createBackupArchive(config); + return { ok: true }; + } catch (error) { + console.error("Failed to create backup archive:", error); + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + }); ipcMain.handle("encrypt-data", async (event, config) => { const { TokenService } = await import("./src/assets/lib/kookit-extra.min.mjs"); diff --git a/src/utils/file/backup.ts b/src/utils/file/backup.ts index f9bc94b6..b85b0276 100644 --- a/src/utils/file/backup.ts +++ b/src/utils/file/backup.ts @@ -44,7 +44,10 @@ export const backup = async (service: string): Promise => { }); // 让 UI 有时间渲染 toast await new Promise((resolve) => setTimeout(resolve, 100)); - await backupFromPath(targetPath, fileName); + const backupResult = await backupFromPath(targetPath, fileName); + if (!backupResult) { + return false; + } if (service === "local") { return true; } else { @@ -158,44 +161,37 @@ export const getSnapshots = () => { }; export const backupFromPath = async (targetPath: string, fileName: string) => { const path = window.require("path"); - const AdmZip = window.require("adm-zip"); const dataPath = getStorageLocation() || ""; - let zip = new AdmZip(); const fs = window.require("fs"); + const { ipcRenderer } = window.require("electron"); if (!fs.existsSync(path.join(targetPath))) { fs.mkdirSync(path.join(targetPath), { recursive: true }); } await backupToConfigJson(); - - if (fs.existsSync(path.join(dataPath, "book"))) { - zip.addLocalFolder(path.join(dataPath, "book"), "book"); - } - if (fs.existsSync(path.join(dataPath, "cover"))) { - zip.addLocalFolder(path.join(dataPath, "cover"), "cover"); - } - if (fs.existsSync(path.join(dataPath, "config", "config.json"))) { - zip.addLocalFile(path.join(dataPath, "config", "config.json"), "config"); - } - if (fs.existsSync(path.join(dataPath, "config", "sync.json"))) { - zip.addLocalFile(path.join(dataPath, "config", "sync.json"), "config"); - } + await backupToSyncJson(); let databaseList = CommonTool.databaseList; for (let i = 0; i < databaseList.length; i++) { - await window.require("electron").ipcRenderer.invoke("close-database", { + await ipcRenderer.invoke("close-database", { dbName: databaseList[i], storagePath: getStorageLocation(), }); - if (fs.existsSync(path.join(dataPath, "config", databaseList[i] + ".db"))) { - zip.addLocalFile( - path.join(dataPath, "config", databaseList[i] + ".db"), - "config" - ); - } } - await zip.writeZip(path.join(targetPath, fileName)); + const result = await ipcRenderer.invoke("stream-backup-zip", { + dataPath, + targetPath, + fileName, + databaseList, + }); + if (!result?.ok) { + console.error("backup zip stream error:", result?.message); + toast.error(result?.message || i18n.t("Backup failed"), { + id: "backup", + }); + return false; + } - // return new Blob([zip.toBuffer()], { type: "application/zip" }); + return true; }; export const backupFromStorage = async () => { let zip = new JSZip();