Files
koodo-reader/main.js
T

2192 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
if (deepLink === pendingDeepLink) {
pendingDeepLink = null;
}
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);
});
var lastDeepLinkUrl = null;
var lastDeepLinkAt = 0;
const handleCallback = (url) => {
try {
// 检查 URL 是否有效
if (!url.startsWith("koodo-reader://")) {
console.error("Invalid URL format:", url);
return;
}
// 同一链接 5 秒内重复派发时忽略,避免确认框弹出两次
// (Windows 冷启动时 pendingDeepLink 与 second-instance 可能都到达)
const now = Date.now();
if (url === lastDeepLinkUrl && now - lastDeepLinkAt < 5000) {
console.info("Ignored duplicate deep link callback");
return;
}
lastDeepLinkUrl = url;
lastDeepLinkAt = now;
// 解析 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);
}
};