mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-10-06 12:53:52 -04:00
2192 lines
69 KiB
JavaScript
2192 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) {
|
||
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);
|
||
}
|
||
};
|