feat: implement backup functionality using streaming zip creation

This commit is contained in:
troyeguo
2026-04-30 17:24:49 +08:00
parent a3c0e1b3d3
commit abb2a764dd
2 changed files with 144 additions and 25 deletions

123
main.js
View File

@@ -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");

View File

@@ -44,7 +44,10 @@ export const backup = async (service: string): Promise<Boolean> => {
});
// 让 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();