const { app, BrowserWindow, WebContentsView, Menu, Tray, nativeImage, ipcMain, dialog, powerSaveBlocker, nativeTheme, protocol, screen, } = require("electron"); const path = require("path"); const isDev = require("electron-is-dev"); const Store = require("electron-store"); 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"); let mainWin; let tray = null; let isQuitting = false; let readerWindow; let readerWindowList = []; let dictWindow; let transWindow; let linkWindow; let mainView; //multi tab // let mainViewList = [] let readerWindowReadyToClose = false; let chatWindow; let dbConnection = {}; let syncUtilCache = {}; let pickerUtilCache = {}; let downloadRequest = null; // Discord Rich Presence setup let discordRPCClient = null; let discordRPCReady = false; let discordRPCConnecting = false; const DISCORD_CLIENT_ID = "1490863275074781305"; // Koodo Reader Discord App ID function initDiscordRPC() { if (discordRPCConnecting || discordRPCReady) return Promise.resolve(); discordRPCConnecting = true; return new Promise((resolve) => { try { const DiscordRPC = require("discord-rpc"); DiscordRPC.register(DISCORD_CLIENT_ID); const client = new DiscordRPC.Client({ transport: "ipc" }); client.on("ready", () => { console.info("Discord RPC connected"); discordRPCClient = client; discordRPCReady = true; discordRPCConnecting = false; resolve(); }); client.login({ clientId: DISCORD_CLIENT_ID }).catch((err) => { console.warn("Discord RPC login failed:", err.message); discordRPCClient = null; discordRPCReady = false; discordRPCConnecting = false; resolve(); }); } catch (e) { console.warn("Discord RPC init failed:", e.message); discordRPCClient = null; discordRPCReady = false; discordRPCConnecting = false; resolve(); } }); } function destroyDiscordRPC() { if (discordRPCClient) { try { discordRPCClient.destroy(); } catch (_) {} discordRPCClient = null; } discordRPCReady = false; discordRPCConnecting = false; } function buildProgressBar(percentage) { const total = 10; const filled = Math.round((percentage / 100) * total); const empty = total - filled; return "▓".repeat(filled) + "░".repeat(empty); } const singleInstance = app.requestSingleInstanceLock(); var filePath = null; if (process.platform != "darwin" && process.argv.length >= 2) { filePath = process.argv[1]; } log.transports.file.fileName = "debug.log"; log.transports.file.maxSize = 1024 * 1024; // 1MB log.initialize(); store.set("appVersion", packageJson.version); store.set("appPlatform", os.platform() + " " + os.release()); const mainWinDisplayScale = store.get("mainWinDisplayScale") || 1; let options = { width: parseInt(store.get("mainWinWidth") || 1050) / mainWinDisplayScale, height: parseInt(store.get("mainWinHeight") || 660) / mainWinDisplayScale, x: parseInt(store.get("mainWinX")), y: parseInt(store.get("mainWinY")), backgroundColor: "#fff", minWidth: 300, minHeight: 100, webPreferences: { webSecurity: false, nodeIntegration: true, contextIsolation: false, nativeWindowOpen: true, nodeIntegrationInSubFrames: false, allowRunningInsecureContent: false, enableRemoteModule: true, sandbox: false, }, }; const Database = require("better-sqlite3"); if (os.platform() === "linux") { options = Object.assign({}, options, { icon: path.join(__dirname, "./build/assets/icon.png"), }); } // Single Instance Lock if (!singleInstance) { app.quit(); } else { app.on("second-instance", (event, argv, workingDir) => { if (mainWin) { if (!mainWin.isVisible()) mainWin.show(); mainWin.focus(); } }); } if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { // Make sure the directory exists if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } fs.writeFileSync( path.join(dirPath, "log.json"), JSON.stringify({ filePath }), "utf-8" ); } const getDBConnection = (dbName, storagePath, sqlStatement) => { if (!dbConnection[dbName]) { if (!fs.existsSync(path.join(storagePath, "config"))) { fs.mkdirSync(path.join(storagePath, "config"), { recursive: true }); } dbConnection[dbName] = new Database( path.join(storagePath, "config", `${dbName}.db`), {} ); dbConnection[dbName].pragma("journal_mode = WAL"); dbConnection[dbName].exec(sqlStatement["createTableStatement"][dbName]); if (sqlStatement["migrateStatement"][dbName]) { let sqlList = sqlStatement["migrateStatement"][dbName]; for (let sql of sqlList) { try { dbConnection[dbName].exec(sql); } catch (error) {} } } } return dbConnection[dbName]; }; const getSyncUtil = async (config, isUseCache = true) => { if (!isUseCache || !syncUtilCache[config.service]) { const { SyncUtil } = await import("./src/assets/lib/kookit-extra.min.mjs"); syncUtilCache[config.service] = new SyncUtil(config.service, config); } return syncUtilCache[config.service]; }; const removeSyncUtil = (config) => { if (syncUtilCache[config.service]) { syncUtilCache[config.service].clearQueue(); delete syncUtilCache[config.service]; } }; const getPickerUtil = async (config, isUseCache = true) => { if (!isUseCache || !pickerUtilCache[config.service]) { const { SyncUtil } = await import("./src/assets/lib/kookit-extra.min.mjs"); pickerUtilCache[config.service] = new SyncUtil(config.service, config); } return pickerUtilCache[config.service]; }; const removePickerUtil = (config) => { if (pickerUtilCache[config.service]) { 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 = ""; for (let i = 0; i < text.length; i++) { const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length); result += String.fromCharCode(charCode); } return Buffer.from(result).toString("base64"); }; // Simple decryption function const decrypt = (encryptedText, key) => { const buff = Buffer.from(encryptedText, "base64").toString(); let result = ""; for (let i = 0; i < buff.length; i++) { const charCode = buff.charCodeAt(i) ^ key.charCodeAt(i % key.length); result += String.fromCharCode(charCode); } return result; }; // Helper to check if two rectangles intersect (for partial visibility) const rectanglesIntersect = (rect1, rect2) => { return !( rect1.x + rect1.width <= rect2.x || rect1.y + rect1.height <= rect2.y || rect1.x >= rect2.x + rect2.width || rect1.y >= rect2.y + rect2.height ); }; // Check if the window is at least partially visible on any display const isWindowPartiallyVisible = (bounds) => { const displays = screen.getAllDisplays(); for (const display of displays) { if (rectanglesIntersect(bounds, display.workArea)) { return true; } } return false; }; const createTray = () => { const iconPath = isDev ? path.join(__dirname, "./public/assets/icon.png") : path.join(__dirname, "./build/assets/icon.png"); let trayIcon = nativeImage.createFromPath(iconPath); if (os.platform() === "darwin") { trayIcon = trayIcon.resize({ width: 16 }); trayIcon.setTemplateImage(true); } tray = new Tray(trayIcon); const contextMenu = Menu.buildFromTemplate([ { label: "Open Koodo Reader", click: () => { if (mainWin) { mainWin.show(); mainWin.focus(); } }, }, { label: "Quit", click: () => { isQuitting = true; app.quit(); }, }, ]); tray.setToolTip("Koodo Reader"); tray.setContextMenu(contextMenu); tray.on("click", () => { if (mainWin) { mainWin.show(); mainWin.focus(); } }); }; const createMainWin = () => { const isMainWindVisible = isWindowPartiallyVisible({ width: parseInt(store.get("mainWinWidth") || 1050) / mainWinDisplayScale, height: parseInt(store.get("mainWinHeight") || 660) / mainWinDisplayScale, x: parseInt(store.get("mainWinX")), y: parseInt(store.get("mainWinY")), }); if (!isMainWindVisible) { delete options.x; delete options.y; } mainWin = new BrowserWindow(options); if (store.get("isAlwaysOnTop") === "yes") { mainWin.setAlwaysOnTop(true); } if (store.get("isAutoMaximizeWin") === "yes") { mainWin.maximize(); } if (!isDev) { Menu.setApplicationMenu(null); } const urlLocation = isDev ? "http://localhost:3000" : `file://${path.join(__dirname, "./build/index.html")}`; mainWin.loadURL(urlLocation); mainWin.on("close", (event) => { if (!isQuitting && store.get("isMinimizeToTray") === "yes") { event.preventDefault(); mainWin.hide(); if (!tray) { createTray(); } return; } if (mainWin && !mainWin.isDestroyed()) { let bounds = mainWin.getBounds(); const currentDisplay = screen.getDisplayMatching(bounds); const primaryDisplay = screen.getPrimaryDisplay(); if (bounds.width > 300 && bounds.height > 100) { store.set({ mainWinWidth: bounds.width, mainWinHeight: bounds.height, mainWinX: mainWin.isMaximized() ? 0 : bounds.x, mainWinY: mainWin.isMaximized() ? 0 : bounds.y, mainWinDisplayScale: currentDisplay.scaleFactor / primaryDisplay.scaleFactor, }); } } mainWin = null; }); mainWin.on("resize", () => { if (mainView) { if (!mainWin) return; let { width, height } = mainWin.getContentBounds(); mainView.setBounds({ x: 0, y: 0, width: width, height: height }); } }); mainWin.on("maximize", () => { if (mainView) { let { width, height } = mainWin.getContentBounds(); mainView.setBounds({ x: 0, y: 0, width: width, height: height }); } }); mainWin.on("unmaximize", () => { if (mainView) { let { width, height } = mainWin.getContentBounds(); mainView.setBounds({ x: 0, y: 0, width: width, height: height }); } }); mainWin.on("focus", () => { if (mainView && !mainView.webContents.isDestroyed()) { mainView.webContents.focus(); } }); mainWin.webContents.on( "console-message", (event, level, message, line, sourceId) => { console.log(`[Renderer Console] Message: ${message}`); } ); //cancel-download-app ipcMain.handle("cancel-download-app", (event, arg) => { // Implement cancellation logic here // Note: In this example, we are not keeping a reference to the request, // so we cannot actually abort it. This is a placeholder for demonstration. if (downloadRequest) { downloadRequest.abort(); downloadRequest = null; } event.returnValue = "cancelled"; }); // Discord RPC handlers ipcMain.handle("discord-rpc-update", async (event, config) => { const { bookTitle, author, percentage } = config; if (!discordRPCReady) { await initDiscordRPC(); } if (!discordRPCClient || !discordRPCReady) return; try { const progressBar = buildProgressBar(percentage); await discordRPCClient.setActivity({ details: bookTitle, state: `${progressBar} ${percentage}% | by ${author}`, largeImageKey: "koodo_reader_logo", largeImageText: "Koodo Reader", startTimestamp: Date.now(), instance: false, buttons: [ { label: "Get Koodo Reader", url: "https://koodoreader.com", }, ], }); } catch (e) { console.warn("Failed to set Discord activity:", e.message); } }); ipcMain.handle("discord-rpc-clear", async (event) => { if (discordRPCClient) { try { await discordRPCClient.clearActivity(); } catch (e) { console.warn("Failed to clear Discord activity:", e.message); } } }); ipcMain.handle("update-win-app", (event, config) => { let fileName = `koodo-reader-installer.exe`; let supportedArchs = ["x64", "ia32", "arm64"]; //get system arch let arch = os.arch(); if (!supportedArchs.includes(arch)) { return; } let url = `https://dl.koodoreader.com/v${config.version}/Koodo-Reader-${config.version}-${arch}.exe`; const https = require("https"); const { spawn } = require("child_process"); const file = fs.createWriteStream(path.join(app.getPath("temp"), fileName)); downloadRequest = https.get(url, (res) => { const totalSize = parseInt(res.headers["content-length"], 10); let downloadedSize = 0; res.on("data", (chunk) => { downloadedSize += chunk.length; const progress = ((downloadedSize / totalSize) * 100).toFixed(2); const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2); const totalMB = (totalSize / 1024 / 1024).toFixed(2); mainWin.webContents.send("download-app-progress", { progress, downloadedMB, totalMB, }); }); res.pipe(file); file.on("finish", () => { console.info("\n下载完成!"); file.close(); let updateExePath = path.join(app.getPath("temp"), fileName); if (!fs.existsSync(updateExePath)) { console.error("更新包不存在:", updateExePath); return; } // 验证文件可执行性 try { fs.accessSync(updateExePath, fs.constants.X_OK); console.info("更新包可执行性验证通过"); } catch (err) { console.error("更新包不可执行:", err.message); return; } try { // 先退出应用,再启动安装程序,避免文件锁定导致覆盖安装失败 app.once("will-quit", () => { const child = spawn(updateExePath, [], { stdio: "ignore", detached: true, shell: true, windowsHide: false, }); child.unref(); }); app.quit(); } catch (err) { console.error(`spawn 执行异常: ${err.message}`); } }); }); }); ipcMain.handle("open-book", (event, config) => { let { url, isMergeWord, isAutoFullscreen, isAutoMaximize, isPreventSleep } = config; if (isMergeWord) { delete options.backgroundColor; } store.set({ url, isMergeWord: isMergeWord || "no", isAutoFullscreen: isAutoFullscreen || "no", isAutoMaximize: isAutoMaximize || "no", isPreventSleep: isPreventSleep || "no", }); let id; if (isPreventSleep === "yes") { id = powerSaveBlocker.start("prevent-display-sleep"); console.info(powerSaveBlocker.isStarted(id)); } if (readerWindow) { readerWindowList.push(readerWindow); } if (isAutoFullscreen === "yes" || isAutoMaximize === "yes") { readerWindow = new BrowserWindow(options); readerWindow.loadURL(url); if (isAutoFullscreen === "yes") { readerWindow.setFullScreen(true); } else if (isAutoMaximize === "yes") { readerWindow.maximize(); } } else { const scaleRatio = store.get("windowDisplayScale") || 1; const isWindowVisible = isWindowPartiallyVisible({ x: parseInt(store.get("windowX")), y: parseInt(store.get("windowY")), width: parseInt(store.get("windowWidth") || 1050) / scaleRatio, height: parseInt(store.get("windowHeight") || 660) / scaleRatio, }); readerWindow = new BrowserWindow({ ...options, width: parseInt(store.get("windowWidth") || 1050) / scaleRatio, height: parseInt(store.get("windowHeight") || 660) / scaleRatio, x: isWindowVisible ? parseInt(store.get("windowX")) : undefined, y: isWindowVisible ? parseInt(store.get("windowY")) : undefined, frame: isMergeWord === "yes" ? false : true, hasShadow: isMergeWord === "yes" ? false : true, transparent: isMergeWord === "yes" ? true : false, }); readerWindow.loadURL(url); // readerWindow.webContents.openDevTools(); } if (store.get("isAlwaysOnTop") === "yes") { readerWindow.setAlwaysOnTop(true); } readerWindowReadyToClose = false; readerWindow.on("close", (event) => { // --- Step 1: ask renderer to flush reading-time data first --- if ( !readerWindowReadyToClose && readerWindow && !readerWindow.isDestroyed() ) { event.preventDefault(); readerWindow.webContents.send("before-reader-close"); return; } // --- Step 2: actual close logic (reached after renderer replied) --- if (readerWindow && !readerWindow.isDestroyed()) { let bounds = readerWindow.getBounds(); const currentDisplay = screen.getDisplayMatching(bounds); const primaryDisplay = screen.getPrimaryDisplay(); if (bounds.width > 300 && bounds.height > 100) { store.set({ windowWidth: bounds.width, windowHeight: bounds.height, windowX: readerWindow.isMaximized() && currentDisplay.id === primaryDisplay.id ? 0 : bounds.x, windowY: readerWindow.isMaximized() && currentDisplay.id === primaryDisplay.id ? 0 : bounds.y < 0 ? 0 : bounds.y, windowDisplayScale: currentDisplay.scaleFactor / primaryDisplay.scaleFactor, }); } } if (isPreventSleep && !readerWindow.isDestroyed()) { id && powerSaveBlocker.stop(id); } if (mainWin && !mainWin.isDestroyed()) { mainWin.webContents.send("reading-finished", {}); } if (discordRPCClient) { try { discordRPCClient.clearActivity(); } catch (e) { console.warn("Failed to clear Discord activity:", e.message); } } }); // Renderer finished flushing reading-time data — proceed with actual close ipcMain.once("reader-close-ready", () => { if (readerWindow && !readerWindow.isDestroyed()) { readerWindowReadyToClose = true; readerWindow.close(); } }); event.returnValue = "success"; }); ipcMain.handle("generate-tts", async (event, voiceConfig) => { let { text, speed, plugin, config } = voiceConfig; let voiceFunc = plugin.script; // eslint-disable-next-line no-eval eval(voiceFunc); return global.getAudioPath(text, speed, dirPath, config); }); ipcMain.handle("cloud-upload", async (event, config) => { let syncUtil = await getSyncUtil(config, config.isUseCache); let result = await syncUtil.uploadFile( config.fileName, config.fileName, config.type ); return result; }); ipcMain.handle("cloud-download", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = await syncUtil.downloadFile( config.fileName, (config.isTemp ? "temp-" : "") + config.fileName, config.type ); return result; }); ipcMain.handle("cloud-progress", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = syncUtil.getDownloadedSize(); return result; }); ipcMain.handle("picker-download", async (event, config) => { let pickerUtil = await getPickerUtil(config); let result = await pickerUtil.remote.downloadFile( config.sourcePath, config.destPath ); return result; }); ipcMain.handle("picker-progress", async (event, config) => { let pickerUtil = await getPickerUtil(config); let result = await pickerUtil.getDownloadedSize(); return result; }); ipcMain.handle("cloud-reset", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = syncUtil.resetCounters(); return result; }); ipcMain.handle("cloud-stats", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = syncUtil.getStats(); return result; }); ipcMain.handle("cloud-delete", async (event, config) => { try { let syncUtil = await getSyncUtil(config, config.isUseCache); let result = await syncUtil.deleteFile(config.fileName, config.type); return result; } catch (error) { console.error("Error deleting file:", error); } return false; }); ipcMain.handle("cloud-list", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = await syncUtil.listFiles(config.type); return result; }); ipcMain.handle("picker-list", async (event, config) => { let pickerUtil = await getPickerUtil(config); let result = await pickerUtil.listFileInfos(config.currentPath); return result; }); ipcMain.handle("cloud-exist", async (event, config) => { let syncUtil = await getSyncUtil(config); let result = await syncUtil.isExist(config.fileName, config.type); return result; }); ipcMain.handle("cloud-close", async (event, config) => { removeSyncUtil(config); return "pong"; }); ipcMain.handle("clear-tts", async (event, config) => { if (!fs.existsSync(path.join(dirPath, "tts"))) { return "pong"; } else { const fsExtra = require("fs-extra"); try { await fsExtra.remove(path.join(dirPath, "tts")); await fsExtra.mkdir(path.join(dirPath, "tts")); return "pong"; } catch (err) { console.error(err); return "pong"; } } }); ipcMain.handle("select-path", async (event) => { var path = await dialog.showOpenDialog({ properties: ["openDirectory"], }); return path.filePaths[0]; }); ipcMain.handle("select-book-path", async (event) => { var result = await dialog.showOpenDialog({ properties: ["openFile"], }); 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"); let fingerprint = await TokenService.getFingerprint(); let encrypted = encrypt(config.token, fingerprint); store.set("encryptedToken", encrypted); return "pong"; }); ipcMain.handle("decrypt-data", async (event) => { let encrypted = store.get("encryptedToken"); if (!encrypted) return ""; const { TokenService } = await import("./src/assets/lib/kookit-extra.min.mjs"); let fingerprint = await TokenService.getFingerprint(); let decrypted = decrypt(encrypted, fingerprint); if (decrypted.startsWith("{") && decrypted.endsWith("}")) { return decrypted; } else { try { const { safeStorage } = require("electron"); decrypted = safeStorage.decryptString(Buffer.from(encrypted, "base64")); let newEncrypted = encrypt(decrypted, fingerprint); store.set("encryptedToken", newEncrypted); return decrypted; } catch (error) { console.error("Decryption failed:", error); return "{}"; } } }); ipcMain.handle("check-cloud-url", async (event, config) => { const https = require("https"); const http = require("http"); const { URL } = require("url"); const { url } = config; return new Promise((resolve) => { let parsedUrl; try { parsedUrl = new URL(url); } catch (e) { return resolve({ ok: false, reason: "invalid_url", detail: e.message }); } const isHttps = parsedUrl.protocol === "https:"; const lib = isHttps ? https : http; const port = parsedUrl.port ? parseInt(parsedUrl.port) : isHttps ? 443 : 80; const options = { hostname: parsedUrl.hostname, port, path: parsedUrl.pathname || "/", method: "HEAD", timeout: 8000, rejectUnauthorized: true, }; const req = lib.request(options, (res) => { resolve({ ok: true, status: res.statusCode, detail: `HTTP ${res.statusCode}`, }); }); req.on("timeout", () => { req.destroy(); resolve({ ok: false, reason: "timeout", detail: `Connection to ${parsedUrl.hostname}:${port} timed out after 8s`, }); }); req.on("error", (err) => { let reason = "unknown"; if (err.code === "ENOTFOUND") { reason = "dns_failed"; } else if (err.code === "ECONNREFUSED") { reason = "connection_refused"; } else if (err.code === "ECONNRESET") { reason = "connection_reset"; } else if (err.code === "ETIMEDOUT") { reason = "timeout"; } else if ( err.code === "CERT_HAS_EXPIRED" || err.code === "ERR_TLS_CERT_ALTNAME_INVALID" || err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" ) { reason = "ssl_error"; } else if (err.message && err.message.includes("SSL")) { reason = "ssl_error"; } resolve({ ok: false, reason, code: err.code || "", detail: err.message, }); }); req.end(); }); }); ipcMain.handle("get-mac", async (event, config) => { const { machineIdSync } = require("node-machine-id"); return machineIdSync(); }); ipcMain.handle("get-store-value", async (event, config) => { return store.get(config.key); }); ipcMain.handle("reset-reader-position", async (event) => { store.delete("windowX"); store.delete("windowY"); return "success"; }); ipcMain.handle("reset-main-position", async (event) => { store.delete("mainWinX"); store.delete("mainWinY"); app.relaunch(); app.exit(); return "success"; }); ipcMain.handle("select-file", async (event, config) => { const result = await dialog.showOpenDialog({ properties: ["openFile"], filters: [{ name: "Zip Files", extensions: ["zip"] }], }); if (result.canceled) { return ""; } else { const filePath = result.filePaths[0]; return filePath; } }); ipcMain.handle("select-book", async (event, config) => { const result = await dialog.showOpenDialog({ properties: ["openFile", "multiSelections"], filters: [ { name: "Books", extensions: [ "epub", "pdf", "txt", "mobi", "azw3", "azw", "htm", "html", "xml", "xhtml", "mhtml", "docx", "md", "fb2", "cbz", "cbt", "cbr", "cb7", ], }, ], }); if (result.canceled) { console.info("User canceled the file selection"); return []; } else { const filePaths = result.filePaths; console.info("Selected file path:", filePaths); return filePaths; } }); ipcMain.handle("custom-database-command", async (event, config) => { const { SqlStatement } = await import("./src/assets/lib/kookit-extra.min.mjs"); let { query, storagePath, data, dbName, executeType } = config; let db = getDBConnection(dbName, storagePath, SqlStatement.sqlStatement); const row = db.prepare(query); let result; if (data && data.length > 0) { result = row[executeType](...data); } else { result = row[executeType](); } return result; }); ipcMain.handle("database-command", async (event, config) => { const { SqlStatement } = await import("./src/assets/lib/kookit-extra.min.mjs"); let { statement, statementType, executeType, dbName, data, storagePath } = config; let db = getDBConnection(dbName, storagePath, SqlStatement.sqlStatement); let sql = ""; if (statementType === "string") { sql = SqlStatement.sqlStatement[statement][dbName]; } else if (statementType === "function") { sql = SqlStatement.sqlStatement[statement][dbName](data); } const row = db.prepare(sql); let result; if (data) { if (statement.startsWith("save") || statement.startsWith("update")) { data = SqlStatement.jsonToSqlite[dbName](data); } result = row[executeType](data); } else { result = row[executeType](); } if (executeType === "all") { return result.map((item) => SqlStatement.sqliteToJson[dbName](item)); } else if (executeType === "get") { return SqlStatement.sqliteToJson[dbName](result); } else { return result; } }); ipcMain.handle("close-database", async (event, config) => { const { SqlStatement } = await import("./src/assets/lib/kookit-extra.min.mjs"); let { dbName, storagePath } = config; let db = getDBConnection(dbName, storagePath, SqlStatement.sqlStatement); delete dbConnection[dbName]; db.close(); }); ipcMain.handle("set-always-on-top", async (event, config) => { store.set("isAlwaysOnTop", config.isAlwaysOnTop); if (mainWin && !mainWin.isDestroyed()) { if (config.isAlwaysOnTop === "yes") { mainWin.setAlwaysOnTop(true); } else { mainWin.setAlwaysOnTop(false); } } if (readerWindow && !readerWindow.isDestroyed()) { if (config.isAlwaysOnTop === "yes") { readerWindow.setAlwaysOnTop(true); } else { readerWindow.setAlwaysOnTop(false); } } return "pong"; }); ipcMain.handle("set-auto-maximize", async (event, config) => { store.set("isAutoMaximizeWin", config.isAutoMaximizeWin); if (mainWin && !mainWin.isDestroyed()) { if (config.isAutoMaximizeWin === "yes") { mainWin.maximize(); } else { mainWin.unmaximize(); } } if (readerWindow && !readerWindow.isDestroyed()) { if (config.isAlwaysOnTop === "yes") { readerWindow.setAlwaysOnTop(true); } else { readerWindow.setAlwaysOnTop(false); } } return "pong"; }); ipcMain.handle("toggle-auto-launch", async (event, config) => { app.setLoginItemSettings({ openAtLogin: config.isAutoLaunch === "yes", }); return "pong"; }); ipcMain.handle("toggle-minimize-to-tray", async (event, config) => { store.set("isMinimizeToTray", config.isMinimizeToTray); if (config.isMinimizeToTray === "no" && tray) { tray.destroy(); tray = null; } return "pong"; }); ipcMain.handle("open-explorer-folder", async (event, config) => { const { shell } = require("electron"); if (config.isFolder) { shell.openPath(config.path); } else { shell.showItemInFolder(config.path); } return "pong"; }); ipcMain.handle("get-debug-logs", async (event, config) => { const { shell } = require("electron"); const file = log.transports.file.getFile(); shell.showItemInFolder(file.path); return "pong"; }); ipcMain.on("user-data", (event, arg) => { event.returnValue = dirPath; }); ipcMain.handle("hide-reader", (event, arg) => { if ( readerWindow && !readerWindow.isDestroyed() && readerWindow.isFocused() ) { readerWindow.minimize(); event.returnvalue = true; } else if (mainWin && mainWin.isFocused()) { mainWin.minimize(); event.returnvalue = true; } else { event.returnvalue = false; } }); ipcMain.handle("open-console", (event, arg) => { mainWin.webContents.openDevTools(); event.returnvalue = true; }); ipcMain.handle("reload-reader", (event, arg) => { if (readerWindowList.length > 0) { readerWindowList.forEach((win) => { if ( win && !win.isDestroyed() && win.webContents.getURL().indexOf(arg.bookKey) > -1 ) { win.reload(); } }); } if ( readerWindow && !readerWindow.isDestroyed() && readerWindow.webContents.getURL().indexOf(arg.bookKey) > -1 ) { readerWindow.reload(); } }); ipcMain.handle("reload-main", (event, arg) => { if (mainWin) { mainWin.reload(); } }); ipcMain.handle("new-chat", (event, config) => { if (!chatWindow && mainWin) { let bounds = mainWin.getBounds(); chatWindow = new BrowserWindow({ ...options, width: 450, height: bounds.height, x: bounds.x + (bounds.width - 450), y: bounds.y, frame: true, hasShadow: true, transparent: false, }); chatWindow.loadURL(config.url); chatWindow.on("close", (event) => { chatWindow && chatWindow.destroy(); chatWindow = null; }); } else if (chatWindow && !chatWindow.isDestroyed()) { chatWindow.show(); chatWindow.focus(); } }); ipcMain.handle("clear-all-data", (event, config) => { store.clear(); }); ipcMain.handle("new-tab", (event, config) => { if (mainWin) { mainView = new WebContentsView(options); mainWin.contentView.addChildView(mainView); let { width, height } = mainWin.getContentBounds(); mainView.setBounds({ x: 0, y: 0, width: width, height: height }); mainView.webContents.loadURL(config.url); } }); ipcMain.handle("reload-tab", (event, config) => { if (mainWin && mainView) { mainView.webContents.reload(); } }); ipcMain.handle("adjust-tab-size", (event, config) => { if (mainWin && mainView) { let { width, height } = mainWin.getContentBounds(); mainView.setBounds({ x: 0, y: 0, width: width, height: height }); } }); ipcMain.handle("exit-tab", (event, message) => { if (mainWin && mainView) { mainWin.contentView.removeChildView(mainView); } if (discordRPCClient) { try { discordRPCClient.clearActivity(); } catch (e) { console.warn("Failed to clear Discord activity:", e.message); } } }); ipcMain.handle("enter-tab-fullscreen", () => { if (mainWin && mainView) { mainWin.setFullScreen(true); console.info("enter full"); } }); ipcMain.handle("exit-tab-fullscreen", () => { if (mainWin && mainView) { mainWin.setFullScreen(false); console.info("exit full"); } }); ipcMain.handle("enter-fullscreen", () => { if (readerWindow) { readerWindow.setFullScreen(true); console.info("enter full"); } }); ipcMain.handle("exit-fullscreen", () => { if (readerWindow && !readerWindow.isDestroyed()) { readerWindow.setFullScreen(false); console.info("exit full"); } }); ipcMain.handle("open-url", (event, config) => { if (config.type === "dict") { if (!dictWindow || dictWindow.isDestroyed()) { dictWindow = new BrowserWindow(); } dictWindow.loadURL(config.url); dictWindow.focus(); } else if (config.type === "trans") { if (!transWindow || transWindow.isDestroyed()) { transWindow = new BrowserWindow(); } transWindow.loadURL(config.url); transWindow.focus(); } else { if (!linkWindow || linkWindow.isDestroyed()) { linkWindow = new BrowserWindow(); } linkWindow.loadURL(config.url); linkWindow.focus(); } event.returnvalue = true; }); ipcMain.handle("switch-moyu", (event, arg) => { let id; if (store.get("isPreventSleep") === "yes") { id = powerSaveBlocker.start("prevent-display-sleep"); console.info(powerSaveBlocker.isStarted(id)); } if (readerWindow && !readerWindow.isDestroyed()) { readerWindowReadyToClose = true; readerWindow.close(); if (store.get("isMergeWord") === "yes") { delete options.backgroundColor; } const scaleRatio = store.get("windowDisplayScale") || 1; Object.assign(options, { width: parseInt(store.get("windowWidth") || 1050) / scaleRatio, height: parseInt(store.get("windowHeight") || 660) / scaleRatio, x: parseInt(store.get("windowX")), y: parseInt(store.get("windowY")), frame: store.get("isMergeWord") !== "yes" ? false : true, hasShadow: store.get("isMergeWord") !== "yes" ? false : true, transparent: store.get("isMergeWord") !== "yes" ? true : false, }); store.set( "isMergeWord", store.get("isMergeWord") !== "yes" ? "yes" : "no" ); if (readerWindow) { readerWindowList.push(readerWindow); } readerWindow = new BrowserWindow(options); if (store.get("isAlwaysOnTop") === "yes") { readerWindow.setAlwaysOnTop(true); } readerWindow.loadURL(store.get("url")); readerWindowReadyToClose = false; readerWindow.on("close", (event) => { // --- Step 1: ask renderer to flush reading-time data first --- if ( !readerWindowReadyToClose && readerWindow && !readerWindow.isDestroyed() ) { event.preventDefault(); readerWindow.webContents.send("before-reader-close"); return; } // --- Step 2: actual close logic (reached after renderer replied) --- if (!readerWindow.isDestroyed()) { let bounds = readerWindow.getBounds(); const currentDisplay = screen.getDisplayMatching(bounds); const primaryDisplay = screen.getPrimaryDisplay(); if (bounds.width > 300 && bounds.height > 100) { store.set({ windowWidth: bounds.width, windowHeight: bounds.height, windowX: readerWindow.isMaximized() && currentDisplay.id === primaryDisplay.id ? 0 : bounds.x, windowY: readerWindow.isMaximized() && currentDisplay.id === primaryDisplay.id ? 0 : bounds.y < 0 ? 0 : bounds.y, }); } } if (store.get("isPreventSleep") && !readerWindow.isDestroyed()) { id && powerSaveBlocker.stop(id); } if (mainWin && !mainWin.isDestroyed()) { mainWin.webContents.send("reading-finished", {}); } if (discordRPCClient) { try { discordRPCClient.clearActivity(); } catch (e) { console.warn("Failed to clear Discord activity:", e.message); } } }); // Renderer finished flushing reading-time data — proceed with actual close ipcMain.once("reader-close-ready", () => { if (readerWindow && !readerWindow.isDestroyed()) { readerWindowReadyToClose = true; readerWindow.close(); } }); } event.returnvalue = false; }); ipcMain.on("storage-location", (event, config) => { event.returnValue = path.join(dirPath, "data"); }); ipcMain.on("url-window-status", (event, config) => { if (config.type === "dict") { event.returnValue = dictWindow && !dictWindow.isDestroyed() ? true : false; } else if (config.type === "trans") { event.returnValue = transWindow && !transWindow.isDestroyed() ? true : false; } else { event.returnValue = linkWindow && !linkWindow.isDestroyed() ? true : false; } }); ipcMain.on("get-dirname", (event, arg) => { event.returnValue = __dirname; }); ipcMain.on("system-color", (event, arg) => { event.returnValue = nativeTheme.shouldUseDarkColors || false; }); ipcMain.on("check-main-open", (event, arg) => { event.returnValue = mainWin ? true : false; }); ipcMain.on("get-file-data", function (event) { if (fs.existsSync(path.join(dirPath, "log.json"))) { try { const _data = JSON.parse( fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}" ); if (_data && _data.filePath) { filePath = _data.filePath; setTimeout(() => { fs.writeFileSync(path.join(dirPath, "log.json"), "{}", "utf-8"); }, 1000); } } catch (error) { console.error("Error reading log.json:", error); } } event.returnValue = filePath; filePath = null; }); ipcMain.on("check-file-data", function (event) { if (fs.existsSync(path.join(dirPath, "log.json"))) { try { const _data = JSON.parse( fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}" ); if (_data && _data.filePath) { filePath = _data.filePath; } } catch (error) { console.error("Error reading log.json:", error); } } event.returnValue = filePath; filePath = null; }); }; app.on("ready", () => { createMainWin(); }); app.on("before-quit", () => { isQuitting = true; destroyDiscordRPC(); }); app.on("window-all-closed", () => { app.quit(); }); app.on("open-file", (e, pathToFile) => { filePath = pathToFile; }); // Register protocol handler app.setAsDefaultProtocolClient("koodo-reader"); // Handle deep linking app.on("second-instance", (event, commandLine) => { const url = commandLine.pop(); if (url) { handleCallback(url); } }); const serializeArg = (arg) => { if (arg === null) return "null"; if (arg === undefined) return "undefined"; if (typeof arg === "object") { try { return JSON.stringify(arg); } catch { return String(arg); } } return String(arg); }; const originalConsoleLog = console.log; console.log = function (...args) { originalConsoleLog(...args); // 保留原日志 log.info(args.map(serializeArg).join(" ")); // 写入日志文件 }; const originalConsoleError = console.error; console.error = function (...args) { originalConsoleError(...args); // 保留原错误日志 log.error(args.map(serializeArg).join(" ")); // 写入错误日志文件 }; const originalConsoleWarn = console.warn; console.warn = function (...args) { originalConsoleWarn(...args); // 保留原警告日志 log.warn(args.map(serializeArg).join(" ")); // 写入警告日志文件 }; const originalConsoleInfo = console.info; console.info = function (...args) { originalConsoleInfo(...args); // 保留原信息日志 log.info(args.map(serializeArg).join(" ")); // 写入信息日志文件 }; // Handle MacOS deep linking app.on("open-url", (event, url) => { event.preventDefault(); handleCallback(url); }); const handleCallback = (url) => { try { // 检查 URL 是否有效 if (!url.startsWith("koodo-reader://")) { console.error("Invalid URL format:", url); return; } // 解析 URL const parsedUrl = new URL(url); const code = parsedUrl.searchParams.get("code"); const state = parsedUrl.searchParams.get("state"); const pickerData = parsedUrl.searchParams.get("pickerData"); if (code && mainWin) { mainWin.webContents.send("oauth-callback", { code, state }); } if (pickerData && mainWin) { let config = JSON.parse(decodeURIComponent(pickerData)); mainWin.webContents.send("picker-finished", config); } } catch (error) { console.error("Error handling callback URL:", error); console.info("Problematic URL:", url); } };