Files
koodo-reader/main.js
T

2177 lines
69 KiB
JavaScript

const {
app,
BrowserWindow,
WebContentsView,
Menu,
Tray,
nativeImage,
ipcMain,
dialog,
powerSaveBlocker,
nativeTheme: electronNativeTheme,
screen,
shell,
clipboard,
net,
session,
} = require("electron");
const path = require("path");
const { pathToFileURL } = require("url");
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 fsExtra = require("fs-extra");
const nodeCrypto = require("crypto");
const yauzl = require("yauzl");
const tarStream = require("tar-stream");
const {
normalizeArchiveEntryName,
ensureEntryInside,
ensureTarIndex,
extractTarByOffsets,
} = require("./src/utils/main/tar-index");
const { runPowerShellScript } = require("./src/utils/main/powershell-util");
const {
buildProxyUrl,
checkCloudUrl,
testProxyConnection,
} = require("./src/utils/main/network-util");
const {
backupToPath,
restoreFromPath,
} = require("./src/utils/main/backup-util");
const {
setDiscordActivity,
clearDiscordActivity,
destroyDiscordRPC,
} = require("./src/utils/main/discord-rpc-util");
const {
resolveOcrLang,
parseOcrImageInput,
writeOcrTempImage,
runWindowsOcr,
runMacosOcr,
} = require("./src/utils/main/ocr-util");
const {
getBiometricCapability,
promptBiometricAuth,
} = require("./src/utils/main/biometric-util");
const { getVoicePlugin } = require("./src/utils/plugins/main/registry");
const configDir = app.getPath("userData");
const dirPath = path.join(configDir, "uploads");
const customVoicePluginCache = new Map();
const resolveCustomVoicePlugin = ({ script, scriptSHA256, pluginKey } = {}) => {
if (
typeof script !== "string" ||
!script ||
typeof scriptSHA256 !== "string" ||
!scriptSHA256
) {
return undefined;
}
const cached = customVoicePluginCache.get(scriptSHA256);
if (cached && cached.script === script && cached.pluginKey === pluginKey) {
return cached;
}
const hash = nodeCrypto.createHash("sha256").update(script).digest("hex");
if (hash !== scriptSHA256.toLowerCase()) {
throw new Error("Custom voice plugin verification failed");
}
delete global.getAudioPath;
delete global.getTTSVoice;
// eslint-disable-next-line no-eval
eval(script);
if (typeof global.getAudioPath !== "function") {
throw new Error("Invalid custom voice plugin script");
}
const resolved = {
script,
scriptSHA256: hash,
pluginKey,
getAudioPath: global.getAudioPath,
getTTSVoice:
typeof global.getTTSVoice === "function" ? global.getTTSVoice : undefined,
};
customVoicePluginCache.set(scriptSHA256, resolved);
return resolved;
};
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;
const RESIZE_THROTTLE_MS = 300;
const throttle = (func, wait = RESIZE_THROTTLE_MS) => {
let lastCall = 0;
let timeoutId = null;
return function (...args) {
const now = Date.now();
const invoke = () => {
lastCall = Date.now();
func.apply(this, args);
};
if (now - lastCall >= wait) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
invoke();
} else if (!timeoutId) {
timeoutId = setTimeout(
() => {
timeoutId = null;
invoke();
},
wait - (now - lastCall)
);
}
};
};
const getFingerprint = async () => {
// First, try to get cached fingerprint
let deviceUuid = store.get("fingerPrint");
if (deviceUuid) {
return deviceUuid;
}
// Try to get machine ID with additional error handling
try {
const { machineIdSync } = require("node-machine-id");
let machineId = machineIdSync();
if (machineId && typeof machineId === "string" && machineId.length > 0) {
// Cache the machine ID for future use
store.set("fingerPrint", machineId);
return machineId;
}
} catch (error) {
console.error("Failed to get machine ID:", error);
}
// Fallback: generate and cache a UUID
let fingerprint = uuidv4().replace(/-/g, "");
store.set("fingerPrint", fingerprint);
return fingerprint;
};
const loadUrlInAuxWindow = async (win, url) => {
const wc = win.webContents;
let currentUrl = "";
try {
currentUrl = wc.getURL();
} catch (_) {
currentUrl = "";
}
if (currentUrl === url) {
wc.reload();
return;
}
let needBlankIntermediate = false;
try {
const current = new URL(currentUrl);
const next = new URL(url);
// When only the hash differs, Chromium treats it as a same-page hashchange
// and won't reload the page. Navigating through about:blank forces a full reload.
needBlankIntermediate =
current.origin === next.origin &&
current.pathname === next.pathname &&
current.search === next.search;
} catch (_) {
// ignore invalid URLs (e.g. empty string, about:blank)
}
if (needBlankIntermediate) {
await wc.loadURL("about:blank");
}
await wc.loadURL(url);
};
const singleInstance = app.requestSingleInstanceLock();
var filePath = null;
var pendingDeepLink = null;
if (process.platform != "darwin" && process.argv.length >= 2) {
filePath = process.argv[1];
// Check argv for a deep link URL (cold start)
for (const arg of process.argv) {
if (arg.startsWith("koodo-reader://")) {
pendingDeepLink = arg;
break;
}
}
}
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:
store.get("appSkin") === "night" ? "rgba(47, 52, 55, 1)" : "#fff",
minWidth: 300,
minHeight: 100,
webPreferences: {
webSecurity: !isDev,
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
nativeWindowOpen: true,
nodeIntegrationInSubFrames: false,
allowRunningInsecureContent: false,
enableRemoteModule: false,
sandbox: true,
},
};
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();
}
// Handle deep link passed via second-instance argv
const deepLink = argv.find((arg) => arg.startsWith("koodo-reader://"));
if (deepLink) {
handleCallback(deepLink);
}
});
}
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 getNativeThemeSource = (appSkin) => {
if (appSkin === "night") {
return "dark";
}
if (appSkin === "light") {
return "light";
}
return "system";
};
const getNativeDarkColorStatus = () => {
if (
typeof electronNativeTheme.shouldUseDarkColorsForSystemIntegratedUI !==
"undefined"
) {
return electronNativeTheme.shouldUseDarkColorsForSystemIntegratedUI;
}
return electronNativeTheme.shouldUseDarkColors;
};
const applyNativeThemeSource = (appSkin) => {
if (process.type !== "browser") {
return false;
}
electronNativeTheme.themeSource = getNativeThemeSource(appSkin);
store.set("appSkin", appSkin || "system");
return getNativeDarkColorStatus();
};
applyNativeThemeSource(store.get("appSkin"));
const applyProxyToSession = async () => {
const { session } = require("electron");
const http = require("http");
const https = require("https");
const config = store.get("proxyConfig");
const defaultSession = session.defaultSession;
const isEnabled =
config && config.type !== "none" && config.enabled !== false && config.host;
if (!isEnabled) {
await defaultSession.setProxy({ mode: "direct" });
https.globalAgent = new https.Agent();
http.globalAgent = new http.Agent();
return;
}
const proxyUrl = buildProxyUrl(config);
let agent = null;
try {
if (config.type === "socks5") {
const { SocksProxyAgent } = require("socks-proxy-agent");
agent = new SocksProxyAgent(proxyUrl);
} else {
const { HttpsProxyAgent } = require("https-proxy-agent");
agent = new HttpsProxyAgent(proxyUrl);
}
} catch (error) {
agent = null;
}
const authentication = config.username
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password || "")}@`
: "";
if (config.type === "http") {
const proxyAddress = `${authentication}${config.host}:${config.port}`;
await defaultSession.setProxy({
proxyRules: `http=${proxyAddress};https=${proxyAddress}`,
});
} else if (config.type === "socks5") {
await defaultSession.setProxy({
proxyRules: `socks5://${config.host}:${config.port}`,
});
}
if (agent) {
https.globalAgent = agent;
http.globalAgent = agent;
}
};
// 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 = () => {
let 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, height: 16, quality: "best" });
trayIcon.setTemplateImage(false);
}
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);
// Handle deep link on cold start: wait for renderer to mount its IPC listeners
mainWin.webContents.once("did-finish-load", () => {
if (pendingDeepLink) {
const link = pendingDeepLink;
pendingDeepLink = null;
// Give React time to register ipcRenderer listeners before dispatching
setTimeout(() => handleCallback(link), 1500);
}
});
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;
});
const syncMainViewBounds = () => {
if (mainView) {
if (!mainWin) return;
let { width, height } = mainWin.getContentBounds();
mainView.setBounds({ x: 0, y: 0, width: width, height: height });
}
};
mainWin.on("resize", throttle(syncMainViewBounds));
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) => {
const lvl =
{ 0: "info", 1: "info", 2: "warn", 3: "error" }[level] || "info";
log[lvl](`[Renderer] ${message}`);
});
//cancel-download-app
const normalizeFileData = (value) => {
if (value instanceof Uint8Array) return Buffer.from(value);
if (value instanceof ArrayBuffer) return Buffer.from(value);
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
return value;
};
const runFileCommand = (args) => {
if (!args || typeof args.operation !== "string") {
throw new TypeError("Invalid file operation");
}
const operation = args.operation;
const filePath = args.path === undefined ? undefined : args.path;
switch (operation) {
case "exists":
return fs.existsSync(filePath);
case "mkdir":
return fs.mkdirSync(filePath, args.options || {});
case "read":
return fs.readFileSync(filePath, args.options);
case "write":
return fs.writeFileSync(
filePath,
normalizeFileData(args.data),
args.options
);
case "append":
return fs.appendFileSync(
filePath,
normalizeFileData(args.data),
args.options
);
case "readdir": {
const entries = fs.readdirSync(filePath, args.options || {});
return args.options && args.options.withFileTypes
? entries.map((entry) => ({
name: entry.name,
isFile: entry.isFile(),
isDirectory: entry.isDirectory(),
}))
: entries;
}
case "stat": {
const stat = fs.statSync(filePath);
return {
size: stat.size,
mtimeMs: stat.mtimeMs,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
case "unlink":
return fs.unlinkSync(filePath);
case "copyFile":
return fs.copyFileSync(args.source, args.destination);
case "rename":
return fs.renameSync(args.source, args.destination);
case "rm":
return fs.rmSync(filePath, args.options || {});
case "emptyDir":
return fsExtra.emptyDirSync(filePath);
case "copy":
return fsExtra.copy(args.source, args.destination);
default:
throw new Error(`Unsupported file operation: ${operation}`);
}
};
ipcMain.on("file-command-sync", (event, args) => {
try {
event.returnValue = { ok: true, value: runFileCommand(args) };
} catch (error) {
event.returnValue = {
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
code:
error && typeof error.code === "string" ? error.code : undefined,
},
};
}
});
ipcMain.handle("file-command", async (event, args) => runFileCommand(args));
ipcMain.on("node-command-sync", (event, args) => {
try {
if (!args || typeof args.operation !== "string") {
throw new TypeError("Invalid Node operation");
}
const stringValue = (value) => {
if (typeof value !== "string") {
throw new TypeError("Invalid string argument");
}
return value;
};
const stringArgs = (values) => {
if (
!Array.isArray(values) ||
values.some((value) => typeof value !== "string")
) {
throw new TypeError("Invalid string arguments");
}
return values;
};
let value;
switch (args.operation) {
case "path-join":
value = path.join(...stringArgs(args.values));
break;
case "path-dirname":
value = path.dirname(stringValue(args.value));
break;
case "path-basename":
value = path.basename(stringValue(args.value), args.suffix);
break;
case "path-extname":
value = path.extname(stringValue(args.value));
break;
case "path-resolve":
value = path.resolve(...stringArgs(args.values));
break;
case "path-posix-join":
value = path.posix.join(...stringArgs(args.values));
break;
case "os-homedir":
value = os.homedir();
break;
case "crypto-md5":
value = nodeCrypto
.createHash("md5")
.update(normalizeFileData(args.data))
.digest("hex");
break;
default:
throw new Error(`Unsupported Node operation: ${args.operation}`);
}
event.returnValue = { ok: true, value };
} catch (error) {
event.returnValue = {
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
code:
error && typeof error.code === "string" ? error.code : undefined,
},
};
}
});
ipcMain.handle("open-external", (event, url) => {
if (typeof url !== "string" || !/^https?:|^mailto:/i.test(url)) {
throw new TypeError("Invalid external URL");
}
return shell.openExternal(url);
});
ipcMain.on("clipboard-read-text-sync", (event) => {
event.returnValue = clipboard.readText();
});
ipcMain.handle("dict-lookup", (event, args) => {
const filePath = args && args.filePath;
if (typeof (args && args.word) !== "string")
throw new TypeError("Invalid dictionary word");
const { MDX } = require("js-mdict");
const result = new MDX(filePath).lookup(args.word);
return result &&
result.definition !== undefined &&
result.definition !== null
? String(result.definition)
: "";
});
ipcMain.handle("partial-md5", (event, filePath) => {
const validatedPath = filePath;
const hash = nodeCrypto.createHash("md5");
const fd = fs.openSync(validatedPath, "r");
try {
const buffer = Buffer.alloc(1024);
for (let i = -1; i <= 10; i++) {
const position = 1024 << (2 * i);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, position);
if (!bytesRead) break;
hash.update(buffer.subarray(0, bytesRead));
}
return hash.digest("hex");
} finally {
fs.closeSync(fd);
}
});
ipcMain.handle("crypto-file-md5", (event, filePath) => {
if (typeof filePath !== "string" || !filePath) {
throw new TypeError("Invalid file path");
}
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${path.basename(filePath)}`);
}
return new Promise((resolve, reject) => {
const hash = nodeCrypto.createHash("md5");
const stream = fs.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
});
});
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) => {
await setDiscordActivity(config);
});
ipcMain.handle("discord-rpc-clear", async (event) => {
await clearDiscordActivity();
});
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.webContents.on(
"console-message",
(_event, level, message) => {
const lvl =
{ 0: "info", 1: "info", 2: "warn", 3: "error" }[level] || "info";
log[lvl](`[Renderer] ${message}`);
}
);
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.webContents.on(
"console-message",
(_event, level, message) => {
const lvl =
{ 0: "info", 1: "info", 2: "warn", 3: "error" }[level] || "info";
log[lvl](`[Renderer] ${message}`);
}
);
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", {});
}
clearDiscordActivity();
});
// 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) => {
const { text, speed, pluginKey, config } = voiceConfig || {};
const plugin =
getVoicePlugin(pluginKey) || resolveCustomVoicePlugin(voiceConfig || {});
if (
!plugin ||
typeof text !== "string" ||
typeof speed !== "number" ||
!Number.isFinite(speed) ||
!config ||
typeof config !== "object" ||
Array.isArray(config)
) {
throw new Error("Invalid TTS plugin request");
}
const audioPath = await plugin.getAudioPath(text, speed, dirPath, config);
if (
typeof audioPath === "string" &&
path.isAbsolute(audioPath) &&
fs.existsSync(audioPath)
) {
return pathToFileURL(audioPath).toString();
}
return audioPath;
});
ipcMain.handle("get-tts-voices", async (event, request) => {
const { pluginKey, config } = request || {};
const plugin =
getVoicePlugin(pluginKey) || resolveCustomVoicePlugin(request || {});
if (
!plugin ||
typeof plugin.getTTSVoice !== "function" ||
!config ||
typeof config !== "object" ||
Array.isArray(config)
) {
throw new Error("Invalid TTS voice request");
}
let voices = [];
try {
voices = await plugin.getTTSVoice(config);
if (!Array.isArray(voices)) {
throw new Error("Invalid TTS voice list");
}
} catch (error) {
console.error("Error getting TTS voices:", error);
throw new Error(`Failed to get TTS voices: ${error.message}`);
}
return voices;
});
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-file", async (event, config) => {
const dialogOptions = { properties: ["openFile"] };
if (config && config.filters) {
dialogOptions.filters = config.filters;
}
var result = await dialog.showOpenDialog(dialogOptions);
return result.filePaths[0];
});
ipcMain.handle("encrypt-data", async (event, config) => {
let fingerprint = await 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 "";
let fingerprint = await 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 { url } = config;
return checkCloudUrl(url);
});
ipcMain.handle("get-proxy-config", async () => {
const config = store.get("proxyConfig") || {
enabled: false,
type: "none",
host: "",
port: 0,
username: "",
password: "",
};
return config;
});
ipcMain.handle("set-proxy-config", async (event, config) => {
const { enabled, type, host, port, username, password } = config || {};
const validTypes = ["none", "http", "socks5"];
if (!validTypes.includes(type)) {
return { ok: false, reason: "invalid_input" };
}
if (type !== "none") {
if (!host || typeof host !== "string" || host.includes("://")) {
return { ok: false, reason: "invalid_input" };
}
const portNumber = parseInt(port);
if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {
return { ok: false, reason: "invalid_input" };
}
}
const finalEnabled = type === "none" ? false : !!enabled;
const configToStore = {
enabled: finalEnabled,
type,
host: type === "none" ? "" : host,
port: type === "none" ? 0 : parseInt(port),
username: type === "none" ? "" : username || "",
password: type === "none" ? "" : password || "",
};
store.set("proxyConfig", configToStore);
await applyProxyToSession();
return { ok: true };
});
ipcMain.handle("test-proxy-connection", async (event, config) => {
return testProxyConnection(config);
});
ipcMain.handle("get-mac", async (event, config) => {
const { machineIdSync } = require("node-machine-id");
return machineIdSync();
});
ipcMain.handle("get-device-name", async () => {
return os.hostname() || "";
});
ipcMain.handle("get-biometric-capability", async () => {
return await getBiometricCapability();
});
ipcMain.handle("prompt-biometric-auth", async (event, config) => {
const senderWindow =
BrowserWindow.fromWebContents(event.sender) ||
BrowserWindow.getFocusedWindow() ||
mainWin ||
null;
return await promptBiometricAuth(config?.message, senderWindow);
});
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-import-file", async (event) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", "multiSelections"],
filters: [{ name: "Data Files", extensions: ["csv", "json"] }],
});
if (result.canceled || !Array.isArray(result.filePaths)) {
return [];
}
return result.filePaths.filter(
(filePath) =>
typeof filePath === "string" &&
["csv", "json"].includes(filePath.split(".").pop().toLowerCase())
);
});
ipcMain.handle("select-zip-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];
// Flush WAL into the main .db file and flip to rollback journal mode so the
// on-disk file is self-contained (header read/write version = 1, no -wal
// dependency). Other consumers — e.g. the Expo app's expo-sqlite
// deserializeDatabaseAsync — can only open a non-WAL SQLite image; without
// this, backups restored there fail with "unable to open database file".
try {
db.pragma("wal_checkpoint(TRUNCATE)");
db.pragma("journal_mode = DELETE");
} catch (error) {
console.error("failed to checkpoint/switch journal mode:", error);
}
db.close();
});
// 流式打包备份:遍历 dataPath 下的固定目录与配置文件,
// 用 yazl 逐文件 addFile 直接写入目标 zip,避免将整库读入内存。
ipcMain.handle("backup-path", async (event, config) => {
return backupToPath(config, (percent) => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("backup-progress", { percent });
}
});
});
// 流式解压恢复:restoreFromPath 直接流式写盘资产文件,config 类文件
// 回传渲染进程处理(.db 经 sql.js 合并,json 写入 ConfigService)。
ipcMain.handle("restore-path", async (event, config) => {
return restoreFromPath(config, (percent) => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("restore-progress", { percent });
}
});
});
// ---- Comic 压缩包(CBZ / CBT / CBR 等)按需解压 ----
// list-*-file:列出压缩包内的条目名;*-file:把指定的条目按需解压到
// dirPath/comic 目录,返回解压后的绝对路径列表。条目路径统一做穿越校验。
const assertExistingArchiveFile = (config, label) => {
const filePath = config && config.filePath;
if (typeof filePath !== "string" || !filePath) {
throw new TypeError(`Invalid ${label} path`);
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
throw new Error(`${label} not found: ${filePath}`);
}
return filePath;
};
const parseArchiveEntries = (entries) => {
if (entries === undefined || entries === null) return null;
if (!Array.isArray(entries) || entries.some((e) => typeof e !== "string")) {
throw new TypeError("Invalid entries: expected an array of entry names");
}
return new Set(entries.map(normalizeArchiveEntryName));
};
ipcMain.handle("list-zip-file", async (event, config) => {
const filePath = assertExistingArchiveFile(config, "zip file");
return new Promise((resolve, reject) => {
const names = [];
yauzl.open(
filePath,
{ lazyEntries: true, autoClose: true },
(err, zipfile) => {
if (err) return reject(err);
zipfile.on("error", reject);
zipfile.on("entry", (entry) => {
if (!/[/\\]$/.test(entry.fileName)) {
const entryPath = normalizeArchiveEntryName(entry.fileName);
names.push({
entryPath,
size: entry.uncompressedSize ?? 0,
fileName: path.posix.basename(entryPath),
});
}
zipfile.readEntry();
});
zipfile.on("end", () => resolve(names));
zipfile.readEntry();
}
);
});
});
ipcMain.handle("unzip-file", async (event, config) => {
const filePath = assertExistingArchiveFile(config, "zip file");
const wanted = parseArchiveEntries(config && config.entries);
const baseDir = path.join(dirPath, "comic");
fs.mkdirSync(baseDir, { recursive: true });
return new Promise((resolve, reject) => {
const results = [];
const fail = (err) => reject(err);
yauzl.open(
filePath,
{ lazyEntries: true, autoClose: true },
(err, zipfile) => {
if (err) return fail(err);
zipfile.on("error", fail);
zipfile.on("end", () => resolve(results));
const advance = () => zipfile.readEntry();
zipfile.on("entry", (entry) => {
const name = normalizeArchiveEntryName(entry.fileName);
const isDir = /[/\\]$/.test(entry.fileName);
const matches = !wanted || wanted.has(name);
if (isDir) {
if (matches) {
try {
fs.mkdirSync(ensureEntryInside(baseDir, name), {
recursive: true,
});
} catch (e) {
return fail(e);
}
}
advance();
return;
}
if (matches) {
zipfile.openReadStream(entry, (openErr, readStream) => {
if (openErr) return fail(openErr);
let dest;
try {
dest = ensureEntryInside(baseDir, name);
fs.mkdirSync(path.dirname(dest), { recursive: true });
} catch (e) {
return fail(e);
}
const output = fs.createWriteStream(dest);
output.on("error", fail);
readStream.on("error", fail);
output.on("close", () => {
results.push(dest);
advance();
});
readStream.pipe(output);
});
} else {
advance();
}
});
zipfile.readEntry();
}
);
});
});
ipcMain.handle("list-tar-file", async (event, config) => {
const filePath = assertExistingArchiveFile(config, "tar file");
const index = await ensureTarIndex(filePath);
if (index.ok) return index.list;
return new Promise((resolve, reject) => {
const names = [];
const extract = tarStream.extract();
extract.on("error", reject);
extract.on("finish", () => resolve(names));
extract.on("entry", (header, stream, next) => {
if (header.type === "file") {
const entryPath = normalizeArchiveEntryName(header.name);
names.push({
entryPath,
size: header.size ?? 0,
fileName: path.posix.basename(entryPath),
});
}
stream.resume();
stream.on("end", next);
});
fs.createReadStream(filePath).on("error", reject).pipe(extract);
});
});
ipcMain.handle("untar-file", async (event, config) => {
const filePath = assertExistingArchiveFile(config, "tar file");
const wanted = parseArchiveEntries(config && config.entries);
const baseDir = path.join(dirPath, "comic");
fs.mkdirSync(baseDir, { recursive: true });
try {
const index = await ensureTarIndex(filePath);
if (index.ok) {
const extracted = await extractTarByOffsets(
filePath,
index,
wanted,
baseDir
);
if (extracted) return extracted;
}
} catch (e) {
console.error("tar offset extract failed, fallback to stream:", e);
}
return new Promise((resolve, reject) => {
const results = [];
let settled = false;
const fail = (err) => {
if (!settled) {
settled = true;
reject(err);
}
};
const extract = tarStream.extract();
extract.on("error", fail);
extract.on("finish", () => {
if (!settled) {
settled = true;
resolve(results);
}
});
extract.on("entry", (header, stream, next) => {
const name = normalizeArchiveEntryName(header.name);
const matches = !wanted || wanted.has(name);
if (header.type === "directory") {
if (matches) {
try {
fs.mkdirSync(ensureEntryInside(baseDir, name), {
recursive: true,
});
} catch (e) {
return fail(e);
}
}
stream.resume();
stream.on("end", next);
return;
}
if (header.type === "file" && matches) {
let dest;
try {
dest = ensureEntryInside(baseDir, name);
fs.mkdirSync(path.dirname(dest), { recursive: true });
} catch (e) {
return fail(e);
}
const output = fs.createWriteStream(dest);
output.on("error", fail);
stream.on("error", fail);
output.on("close", () => {
results.push(dest);
next();
});
stream.pipe(output);
return;
}
stream.resume();
stream.on("end", next);
});
fs.createReadStream(filePath).on("error", fail).pipe(extract);
});
});
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,
webPreferences: {
...options.webPreferences,
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "chat-preload.js"),
},
});
chatWindow.loadURL(config.url);
chatWindow.on("close", (event) => {
chatWindow && chatWindow.destroy();
chatWindow = null;
});
} else if (chatWindow && !chatWindow.isDestroyed()) {
chatWindow.show();
chatWindow.focus();
}
});
ipcMain.on("chat-message", (event, msg) => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("chat-message", msg);
}
});
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);
mainView.webContents.on("console-message", (_event, level, message) => {
const lvl =
{ 0: "info", 1: "info", 2: "warn", 3: "error" }[level] || "info";
log[lvl](`[Renderer] ${message}`);
});
}
});
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) => {
return new Promise((resolve) => {
const doRemoveTab = () => {
if (mainWin && mainView) {
mainWin.webContents.send("reading-finished", {});
mainWin.contentView.removeChildView(mainView);
}
clearDiscordActivity();
resolve(undefined);
};
// Ask the tab renderer to flush reading-time data first, then close
if (mainView && !mainView.webContents.isDestroyed()) {
const timeoutId = setTimeout(() => {
// Fallback: if renderer doesn't reply within 3s, close anyway
ipcMain.removeListener("tab-close-ready", onTabCloseReady);
doRemoveTab();
}, 3000);
const onTabCloseReady = () => {
clearTimeout(timeoutId);
doRemoveTab();
};
ipcMain.once("tab-close-ready", onTabCloseReady);
mainView.webContents.send("before-tab-close");
} else {
doRemoveTab();
}
});
});
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("exit-reader", () => {
if (readerWindow && !readerWindow.isDestroyed()) {
readerWindow.close();
}
});
ipcMain.handle("open-url", async (event, config) => {
if (config.type === "dict") {
if (!dictWindow || dictWindow.isDestroyed()) {
dictWindow = new BrowserWindow();
}
dictWindow.focus();
await loadUrlInAuxWindow(dictWindow, config.url);
} else if (config.type === "trans") {
if (!transWindow || transWindow.isDestroyed()) {
transWindow = new BrowserWindow();
}
transWindow.focus();
await loadUrlInAuxWindow(transWindow, config.url);
} 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.webContents.on(
"console-message",
(_event, level, message) => {
const lvl =
{ 0: "info", 1: "info", 2: "warn", 3: "error" }[level] || "info";
log[lvl](`[Renderer] ${message}`);
}
);
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", {});
}
clearDiscordActivity();
});
// 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 = getNativeDarkColorStatus() || false;
});
ipcMain.handle("set-native-theme-source", (event, appSkin) => {
return applyNativeThemeSource(appSkin);
});
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;
});
ipcMain.handle("system-ocr", async (event, config) => {
const { base64, lang } = config || {};
let tempPath = null;
try {
const { buffer, ext } = parseOcrImageInput(base64);
tempPath = writeOcrTempImage(buffer, ext);
const { macos, win } = resolveOcrLang(lang);
let text = "";
if (process.platform === "darwin") {
text = await runMacosOcr(tempPath, macos);
} else if (process.platform === "win32") {
text = await runWindowsOcr(tempPath, win);
} else {
return {
ok: false,
error: "System OCR is only supported on Windows and macOS",
};
}
return { ok: true, text };
} catch (error) {
log.error("system-ocr failed:", error.message);
return { ok: false, error: error.message || "OCR failed" };
} finally {
if (tempPath) {
try {
fs.unlinkSync(tempPath);
} catch (e) {
// 忽略清理失败
}
}
}
});
};
const applyCorsToRendererRequests = () => {
const filter = {
urls: ["http://*/*", "https://*/*"],
};
session.defaultSession.webRequest.onHeadersReceived(
filter,
(details, callback) => {
const responseHeaders = { ...details.responseHeaders };
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods":
"GET, POST, PUT, DELETE, OPTIONS, PATCH",
"Access-Control-Allow-Headers": "*, Authorization",
"Access-Control-Expose-Headers": "*",
};
if (details.method === "OPTIONS") {
corsHeaders["Access-Control-Max-Age"] = "86400";
}
for (const [name, value] of Object.entries(corsHeaders)) {
for (const existingName of Object.keys(responseHeaders)) {
if (existingName.toLowerCase() === name.toLowerCase()) {
delete responseHeaders[existingName];
}
}
responseHeaders[name] = [value];
}
callback({ responseHeaders });
}
);
};
app.on("ready", async () => {
applyCorsToRendererRequests();
await applyProxyToSession();
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");
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");
const bookKey = parsedUrl.searchParams.get("bookKey");
const noteKey = parsedUrl.searchParams.get("noteKey");
const importUrl = parsedUrl.searchParams.get("importUrl");
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);
}
if (bookKey && mainWin) {
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("open-book-from-link", { bookKey });
}
if (noteKey && mainWin) {
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("open-note-from-link", { noteKey });
}
if (importUrl && mainWin) {
const decodedUrl = decodeURIComponent(importUrl);
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("import-url-from-link", { url: decodedUrl });
}
} catch (error) {
console.error("Error handling callback URL:", error);
console.info("Problematic URL:", url);
}
};