Files
koodo-reader/main.js
T

3063 lines
98 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,
systemPreferences,
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 { execFile } = require("child_process");
const store = new Store();
const fs = require("fs");
const fsExtra = require("fs-extra");
const nodeCrypto = require("crypto");
const yazl = require("yazl");
const yauzl = require("yauzl");
const AdmZip = require("adm-zip");
const { getVoicePlugin } = require("./src/utils/plugins/main/registry");
const configDir = app.getPath("userData");
const dirPath = path.join(configDir, "uploads");
const packageJson = require("./package.json");
let mainWin;
let tray = null;
let isQuitting = false;
let readerWindow;
let readerWindowList = [];
let dictWindow;
let transWindow;
let linkWindow;
let mainView;
//multi tab
// let mainViewList = []
let readerWindowReadyToClose = false;
let chatWindow;
let dbConnection = {};
let syncUtilCache = {};
let pickerUtilCache = {};
let downloadRequest = null;
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 extractClixmlErrors = (text) => {
if (!text) return "";
const matches = text.match(
/<S S="Error">([^<]*(?:<[^/][^>]*>[^<]*<\/[^>]*>)*[^<]*)<\/S>/g
);
if (!matches) return text;
return matches
.map((m) =>
m
.replace(/<\/?S[^>]*>/g, "")
.replace(/<[^>]+>/g, "")
.replace(/_x000D__x000A_/g, "\n")
.trim()
)
.filter(Boolean)
.join("\n");
};
const runPowerShellScript = (script, timeout = 30000) => {
return new Promise((resolve, reject) => {
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
execFile(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Sta",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodedCommand,
],
{
windowsHide: true,
timeout,
maxBuffer: 1024 * 1024,
},
(error, stdout, stderr) => {
if (error) {
const rawMessage = (stderr || stdout || error.message || "").trim();
const cleanMessage = extractClixmlErrors(rawMessage) || rawMessage;
reject(new Error(cleanMessage));
return;
}
resolve((stdout || "").trim());
}
);
});
};
const OCR_TEMP_DIR = path.join(configDir, "ocr-tmp");
// macOS OCR 二进制支持的语言(VNRecognizeTextRequest recognitionLanguages
const MACOS_OCR_LANGS = new Set([
"zh-Hans",
"zh-Hant",
"en-US",
"ja-JP",
"ko-KR",
"fr-FR",
]);
// 把渲染进程传入的语言代码映射为各平台可识别的标签
// key: 应用内统一代码;value: { macos, win }
const OCR_LANG_MAP = {
"zh-CN": { macos: "zh-Hans", win: "zh-Hans-CN" },
"zh-SG": { macos: "zh-Hans", win: "zh-Hans-CN" },
"zh-TW": { macos: "zh-Hant", win: "zh-Hant-TW" },
"zh-HK": { macos: "zh-Hant", win: "zh-Hant-HK" },
"zh-Hans": { macos: "zh-Hans", win: "zh-Hans-CN" },
"zh-Hant": { macos: "zh-Hant", win: "zh-Hant-TW" },
en: { macos: "en-US", win: "en-US" },
"en-US": { macos: "en-US", win: "en-US" },
"en-GB": { macos: "en-US", win: "en-GB" },
ja: { macos: "ja-JP", win: "ja" },
"ja-JP": { macos: "ja-JP", win: "ja" },
ko: { macos: "ko-KR", win: "ko" },
"ko-KR": { macos: "ko-KR", win: "ko" },
fr: { macos: "fr-FR", win: "fr" },
"fr-FR": { macos: "fr-FR", win: "fr" },
};
const resolveOcrLang = (lang) => {
if (!lang || lang === "auto") return { macos: "auto", win: "auto" };
return OCR_LANG_MAP[lang] || { macos: lang, win: lang };
};
// 从 base64 或 dataURL 中解析出 { buffer, ext }
const parseOcrImageInput = (input) => {
if (typeof input !== "string" || !input) {
throw new Error("Invalid image data");
}
// dataURL: data:image/png;base64,xxxx
const dataUrlMatch = input.match(/^data:image\/([a-zA-Z0-9]+);base64,(.+)$/);
if (dataUrlMatch) {
const ext =
dataUrlMatch[1].toLowerCase() === "jpeg"
? "jpg"
: dataUrlMatch[1].toLowerCase();
return { buffer: Buffer.from(dataUrlMatch[2], "base64"), ext };
}
// 纯 base64,按 PNG 处理
return { buffer: Buffer.from(input, "base64"), ext: "png" };
};
const writeOcrTempImage = (buffer, ext) => {
if (!fs.existsSync(OCR_TEMP_DIR)) {
fs.mkdirSync(OCR_TEMP_DIR, { recursive: true });
}
const fileName = `ocr-${process.pid}-${Date.now()}.${ext}`;
const filePath = path.join(OCR_TEMP_DIR, fileName);
fs.writeFileSync(filePath, buffer);
return filePath;
};
const cleanWindowsOcrText = (text) => {
if (!text) return text;
// Windows.Media.Ocr 对中日韩等无词边界的语言按"字"分词,Text 用空格连接,
// 导致中文每字之间出现空格。循环去除 CJK 文字/全角标点之间的空格,
// 保留英文与数字之间的空格。单次 replace 无法合并连续序列(如"符 号 学"),
// 需循环直到无变化。
//
// CJK 范围用 Unicode 码点表示:
// 一-龿 CJK 统一汉字(基本区)
// 㐀-䶿 CJK 扩展 A 区
// ぀-ヿ 日文平假名 / 片假名
// 가-힯 韩文谚文音节
//  -〿 CJK 符号与标点(全角空格、· 、。 等)
// ＀-￯ 全角符号(全角字母数字、() 等)
const cjk =
"\\u4e00-\\u9fbf\\u3400-\\u4dbf\\u3040-\\u30ff\\uac00-\\ud7af\\u3000-\\u303f\\uff00-\\uffef";
const pattern = new RegExp("([" + cjk + "])\\s+([" + cjk + "])", "gu");
let prev;
let cur = text;
do {
prev = cur;
cur = cur.replace(pattern, "$1$2");
} while (cur !== prev);
return cur;
};
// Windows: 通过 PowerShell 调用 Windows.Media.Ocr (WinRT)
const runWindowsOcr = (imagePath, winLang) => {
// PowerShell 脚本里用单引号包裹路径,需转义内部单引号
const escapePsSingle = (s) => s.replace(/'/g, "''");
const escapedPath = escapePsSingle(imagePath);
const langClause =
winLang === "auto"
? "[Windows.Media.Ocr.OcrEngine,Windows.Media.Ocr,ContentType=WindowsRuntime]::TryCreateFromUserProfileLanguages()"
: "$( $__lang = [Windows.Globalization.Language,Windows.Globalization,ContentType=WindowsRuntime]::new('" +
escapePsSingle(winLang) +
"'); [Windows.Media.Ocr.OcrEngine,Windows.Media.Ocr,ContentType=WindowsRuntime]::TryCreateFromLanguage($__lang) )";
const script = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | ? { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function Await($WinRtTask, $ResultType) {
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
function EncodeOut($prefix, $text) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
Write-Output -NoEnumerate ($prefix + [Convert]::ToBase64String($bytes))
}
try {
$path = '${escapedPath}'
$file = Await ([Windows.Storage.StorageFile,Windows.Storage,ContentType=WindowsRuntime]::GetFileFromPathAsync($path)) ([Windows.Storage.StorageFile])
$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder,Windows.Graphics.Imaging,ContentType=WindowsRuntime]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$engine = ${langClause}
if ($null -eq $engine) { Write-Output 'LANGERR'; exit 0 }
$result = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
EncodeOut 'OK' $result.Text
} catch {
EncodeOut 'ERR' $_.Exception.Message
exit 0
}
`;
return runPowerShellScript(script, 60000).then((text) => {
const trimmed = (text || "").trim();
if (!trimmed) {
throw new Error("Windows OCR returned empty result");
}
if (trimmed === "LANGERR") {
const err = new Error(
"Language package not installed! See: https://support.microsoft.com/help/17213"
);
err.code = "LANG_NOT_INSTALLED";
throw err;
}
if (trimmed.startsWith("ERR")) {
const msg = Buffer.from(trimmed.slice(3), "base64")
.toString("utf8")
.trim();
throw new Error(msg || "Windows OCR failed");
}
if (trimmed.startsWith("OK")) {
const b64 = trimmed.slice(2);
const raw = b64 ? Buffer.from(b64, "base64").toString("utf8") : "";
return cleanWindowsOcrText(raw);
}
throw new Error("Windows OCR returned unexpected output");
});
};
// macOS: 调用打包的 Vision framework 二进制
const runMacosOcr = (imagePath, macosLang) => {
const arch = process.arch; // arm64 / x64
const archName =
arch === "arm64" ? "aarch64" : arch === "x64" ? "x86_64" : arch;
const binPath = isDev
? path.join(__dirname, "assets/macos/ocr-" + archName + "-apple-darwin")
: path.join(
process.resourcesPath,
"assets/macos/ocr-" + archName + "-apple-darwin"
);
if (!fs.existsSync(binPath)) {
const err = new Error("macOS OCR binary not found: " + binPath);
err.code = "BIN_NOT_FOUND";
throw err;
}
return new Promise((resolve, reject) => {
execFile(
binPath,
[imagePath, macosLang],
{ timeout: 60000, maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
const msg = (stderr || error.message || "").trim();
const err = new Error(msg || "macOS OCR failed");
err.code = "BIN_FAILED";
reject(err);
return;
}
resolve((stdout || "").trim());
}
);
});
};
const getWindowHandleValue = (win) => {
if (!win || typeof win.getNativeWindowHandle !== "function") {
return "";
}
try {
const handle = win.getNativeWindowHandle();
if (!Buffer.isBuffer(handle) || handle.length === 0) {
return "";
}
if (handle.length >= 8 && typeof handle.readBigUInt64LE === "function") {
return handle.readBigUInt64LE(0).toString();
}
return handle.readUInt32LE(0).toString();
} catch (error) {
console.warn("Failed to resolve native window handle:", error);
return "";
}
};
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 getWindowsHelloScript = (mode, message = "", hwnd = "") => {
const escapedMessage = message.replace(/'/g, "''");
const escapedHwnd = String(hwnd || "").replace(/'/g, "''");
return `
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Add-Type -AssemblyName System.Runtime.WindowsRuntime
function Invoke-WinRtAsync {
param(
[Parameter(Mandatory = $true)] $Operation,
[Parameter(Mandatory = $true)] [Type[]] $ResultTypes
)
$method = [System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object {
$_.Name -eq 'AsTask' -and
$_.IsGenericMethodDefinition -and
$_.GetGenericArguments().Count -eq $ResultTypes.Count -and
$_.GetParameters().Count -eq 1
} |
Select-Object -First 1
if (-not $method) {
throw 'Unable to bridge Windows Runtime async operation.'
}
$genericMethod = $method.MakeGenericMethod($ResultTypes)
$task = $genericMethod.Invoke($null, @($Operation))
return $task.GetAwaiter().GetResult()
}
function Request-WindowsHelloVerification {
param(
[Parameter(Mandatory = $true)] [string] $Message,
[string] $Hwnd
)
$isWindowInteropSupported = [Environment]::OSVersion.Version.Build -ge 22000 -and -not [string]::IsNullOrWhiteSpace($Hwnd)
if (-not $isWindowInteropSupported) {
return Invoke-WinRtAsync -Operation ($verifier::RequestVerificationAsync($Message)) -ResultTypes @([Windows.Security.Credentials.UI.UserConsentVerificationResult])
}
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
namespace KoodoReaderInterop
{
[ComImport]
[Guid("39E050C3-4E74-441A-8DC0-B81104DF949C")]
[InterfaceType(ComInterfaceType.InterfaceIsIInspectable)]
public interface IUserConsentVerifierInterop
{
[return: MarshalAs(UnmanagedType.IInspectable)]
object RequestVerificationForWindowAsync(
IntPtr appWindow,
[MarshalAs(UnmanagedType.HString)] string message,
[In] ref Guid riid);
}
public static class UserConsentVerifierInteropHelper
{
public static object RequestVerificationForWindow(object activationFactory, long hwnd, string message, Guid riid)
{
IntPtr ptr = IntPtr.Zero;
try
{
ptr = Marshal.GetIUnknownForObject(activationFactory);
var interop = (IUserConsentVerifierInterop)Marshal.GetTypedObjectForIUnknown(ptr, typeof(IUserConsentVerifierInterop));
return interop.RequestVerificationForWindowAsync(new IntPtr(hwnd), message, ref riid);
}
finally
{
if (ptr != IntPtr.Zero)
{
Marshal.Release(ptr);
}
}
}
}
}
"@
$activationFactory = [System.Runtime.InteropServices.WindowsRuntime.WindowsRuntimeMarshal]::GetActivationFactory($verifier)
$asyncOperationGuid = [Guid]::Parse('fd596ffd-2318-558f-9dbe-d21df43764a5')
$operation = [KoodoReaderInterop.UserConsentVerifierInteropHelper]::RequestVerificationForWindow($activationFactory, [Int64]::Parse($Hwnd), $Message, $asyncOperationGuid)
return Invoke-WinRtAsync -Operation $operation -ResultTypes @([Windows.Security.Credentials.UI.UserConsentVerificationResult])
}
$verifier = [Windows.Security.Credentials.UI.UserConsentVerifier, Windows.Security.Credentials.UI, ContentType = WindowsRuntime]
$availability = Invoke-WinRtAsync -Operation ($verifier::CheckAvailabilityAsync()) -ResultTypes @([Windows.Security.Credentials.UI.UserConsentVerifierAvailability])
if ('${mode}' -eq 'check') {
[Console]::Out.Write((@{
available = ($availability.ToString() -eq 'Available')
status = $availability.ToString()
} | ConvertTo-Json -Compress))
exit 0
}
if ($availability.ToString() -ne 'Available') {
[Console]::Out.Write((@{
success = $false
code = 'Unavailable'
status = $availability.ToString()
} | ConvertTo-Json -Compress))
exit 0
}
try {
$result = Request-WindowsHelloVerification -Message '${escapedMessage}' -Hwnd '${escapedHwnd}'
[Console]::Out.Write((@{
success = ($result.ToString() -eq 'Verified')
code = $result.ToString()
status = $availability.ToString()
} | ConvertTo-Json -Compress))
} catch {
[Console]::Out.Write((@{
success = $false
code = 'Error'
status = $_.Exception.Message
} | ConvertTo-Json -Compress))
}
`.trim();
};
const getBiometricCapability = async () => {
if (process.platform === "darwin") {
const available =
typeof systemPreferences.canPromptTouchID === "function" &&
systemPreferences.canPromptTouchID();
return {
available,
provider: "Touch ID",
platform: process.platform,
status: available ? "Available" : "Unavailable",
};
}
if (process.platform === "win32") {
try {
const output = await runPowerShellScript(getWindowsHelloScript("check"));
const result = output ? JSON.parse(output) : {};
return {
available: !!result.available,
provider: "Windows Hello",
platform: process.platform,
status: result.status || "Unavailable",
};
} catch (error) {
return {
available: false,
provider: "Windows Hello",
platform: process.platform,
status: "Error",
error: error instanceof Error ? error.message : String(error),
};
}
}
return {
available: false,
provider: "Biometric",
platform: process.platform,
status: "Unsupported",
};
};
const promptBiometricAuth = async (
promptMessage = "Authenticate",
owningWindow = null
) => {
if (process.platform === "darwin") {
const available =
typeof systemPreferences.canPromptTouchID === "function" &&
systemPreferences.canPromptTouchID();
if (!available) {
return {
success: false,
code: "Unavailable",
provider: "Touch ID",
};
}
try {
await systemPreferences.promptTouchID(promptMessage);
return {
success: true,
code: "Verified",
provider: "Touch ID",
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
code: /cancel/i.test(message) ? "Canceled" : "Failed",
provider: "Touch ID",
};
}
}
if (process.platform === "win32") {
try {
const hwnd = getWindowHandleValue(owningWindow);
const output = await runPowerShellScript(
getWindowsHelloScript("verify", promptMessage, hwnd),
120000
);
const result = output ? JSON.parse(output) : {};
return {
success: !!result.success,
code:
result.code === "Unavailable" && result.status
? result.status
: result.code || "Error",
provider: "Windows Hello",
};
} catch (error) {
console.error("Biometric verification error:", error.message);
return {
success: false,
code: "Error",
provider: "Windows Hello",
};
}
}
return {
success: false,
code: "Unsupported",
provider: "Biometric",
};
};
// Discord Rich Presence setup
let discordRPCClient = null;
let discordRPCReady = false;
let discordRPCConnecting = false;
const DISCORD_CLIENT_ID = "1490863275074781305"; // Koodo Reader Discord App ID
function initDiscordRPC() {
if (discordRPCConnecting || discordRPCReady) return Promise.resolve();
discordRPCConnecting = true;
return new Promise((resolve) => {
try {
const DiscordRPC = require("discord-rpc");
DiscordRPC.register(DISCORD_CLIENT_ID);
const client = new DiscordRPC.Client({ transport: "ipc" });
client.on("ready", () => {
console.info("Discord RPC connected");
discordRPCClient = client;
discordRPCReady = true;
discordRPCConnecting = false;
resolve();
});
client.login({ clientId: DISCORD_CLIENT_ID }).catch((err) => {
console.warn("Discord RPC login failed:", err.message);
discordRPCClient = null;
discordRPCReady = false;
discordRPCConnecting = false;
resolve();
});
} catch (e) {
console.warn("Discord RPC init failed:", e.message);
discordRPCClient = null;
discordRPCReady = false;
discordRPCConnecting = false;
resolve();
}
});
}
function destroyDiscordRPC() {
if (discordRPCClient) {
try {
discordRPCClient.destroy();
} catch (_) {}
discordRPCClient = null;
}
discordRPCReady = false;
discordRPCConnecting = false;
}
function buildProgressBar(percentage) {
const total = 10;
const filled = Math.round((percentage / 100) * total);
const empty = total - filled;
return "▓".repeat(filled) + "░".repeat(empty);
}
const singleInstance = app.requestSingleInstanceLock();
var filePath = null;
var pendingDeepLink = null;
if (process.platform != "darwin" && process.argv.length >= 2) {
filePath = process.argv[1];
// Check argv for a deep link URL (cold start)
for (const arg of process.argv) {
if (arg.startsWith("koodo-reader://")) {
pendingDeepLink = arg;
break;
}
}
}
log.transports.file.fileName = "debug.log";
log.transports.file.maxSize = 1024 * 1024; // 1MB
log.initialize();
store.set("appVersion", packageJson.version);
store.set("appPlatform", os.platform() + " " + os.release());
const mainWinDisplayScale = store.get("mainWinDisplayScale") || 1;
let options = {
width: parseInt(store.get("mainWinWidth") || 1050) / mainWinDisplayScale,
height: parseInt(store.get("mainWinHeight") || 660) / mainWinDisplayScale,
x: parseInt(store.get("mainWinX")),
y: parseInt(store.get("mainWinY")),
backgroundColor:
store.get("appSkin") === "night" ? "rgba(47, 52, 55, 1)" : "#fff",
minWidth: 300,
minHeight: 100,
webPreferences: {
webSecurity: !isDev,
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
nativeWindowOpen: true,
nodeIntegrationInSubFrames: false,
allowRunningInsecureContent: false,
enableRemoteModule: false,
sandbox: true,
},
};
const Database = require("better-sqlite3");
if (os.platform() === "linux") {
options = Object.assign({}, options, {
icon: path.join(__dirname, "./build/assets/icon.png"),
});
}
// Single Instance Lock
if (!singleInstance) {
app.quit();
} else {
app.on("second-instance", (event, argv, workingDir) => {
if (mainWin) {
if (!mainWin.isVisible()) mainWin.show();
mainWin.focus();
}
// Handle deep link passed via second-instance argv
const deepLink = argv.find((arg) => arg.startsWith("koodo-reader://"));
if (deepLink) {
handleCallback(deepLink);
}
});
}
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
// Make sure the directory exists
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
path.join(dirPath, "log.json"),
JSON.stringify({ filePath }),
"utf-8"
);
}
const getDBConnection = (dbName, storagePath, sqlStatement) => {
if (!dbConnection[dbName]) {
if (!fs.existsSync(path.join(storagePath, "config"))) {
fs.mkdirSync(path.join(storagePath, "config"), { recursive: true });
}
dbConnection[dbName] = new Database(
path.join(storagePath, "config", `${dbName}.db`),
{}
);
dbConnection[dbName].pragma("journal_mode = WAL");
dbConnection[dbName].exec(sqlStatement["createTableStatement"][dbName]);
if (sqlStatement["migrateStatement"][dbName]) {
let sqlList = sqlStatement["migrateStatement"][dbName];
for (let sql of sqlList) {
try {
dbConnection[dbName].exec(sql);
} catch (error) {}
}
}
}
return dbConnection[dbName];
};
const getSyncUtil = async (config, isUseCache = true) => {
if (!isUseCache || !syncUtilCache[config.service]) {
const { SyncUtil } = await import("./src/assets/lib/kookit-extra.min.mjs");
syncUtilCache[config.service] = new SyncUtil(config.service, config);
}
return syncUtilCache[config.service];
};
const removeSyncUtil = (config) => {
if (syncUtilCache[config.service]) {
syncUtilCache[config.service].clearQueue();
delete syncUtilCache[config.service];
}
};
const getPickerUtil = async (config, isUseCache = true) => {
if (!isUseCache || !pickerUtilCache[config.service]) {
const { SyncUtil } = await import("./src/assets/lib/kookit-extra.min.mjs");
pickerUtilCache[config.service] = new SyncUtil(config.service, config);
}
return pickerUtilCache[config.service];
};
const removePickerUtil = (config) => {
if (pickerUtilCache[config.service]) {
pickerUtilCache[config.service] = null;
}
};
const getNativeThemeSource = (appSkin) => {
if (appSkin === "night") {
return "dark";
}
if (appSkin === "light") {
return "light";
}
return "system";
};
const getNativeDarkColorStatus = () => {
if (
typeof electronNativeTheme.shouldUseDarkColorsForSystemIntegratedUI !==
"undefined"
) {
return electronNativeTheme.shouldUseDarkColorsForSystemIntegratedUI;
}
return electronNativeTheme.shouldUseDarkColors;
};
const applyNativeThemeSource = (appSkin) => {
if (process.type !== "browser") {
return false;
}
electronNativeTheme.themeSource = getNativeThemeSource(appSkin);
store.set("appSkin", appSkin || "system");
return getNativeDarkColorStatus();
};
applyNativeThemeSource(store.get("appSkin"));
const buildProxyUrl = (config) => {
const authentication =
config.username || config.password
? `${encodeURIComponent(config.username || "")}:${encodeURIComponent(config.password || "")}@`
: "";
const portNumber = parseInt(config.port);
return config.type === "socks5"
? `socks5://${authentication}${config.host}:${portNumber}`
: `http://${authentication}${config.host}:${portNumber}`;
};
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));
// adm-zipsnapshot 生成/还原与旧备份格式回退使用(与备份主流程的
// yazl/yauzl 流式处理并存;此处处理的均为配置类小文件,同步解压即可)。
ipcMain.handle("zip-command", async (event, args) => {
if (!args || typeof args.operation !== "string") {
throw new TypeError("Invalid zip operation");
}
const stringValue = (value, key) => {
if (typeof value !== "string" || !value) {
throw new TypeError(`${key} must be a non-empty string`);
}
return value;
};
switch (args.operation) {
case "list": {
const filePath = stringValue(args.filePath, "filePath");
return new AdmZip(filePath)
.getEntries()
.filter((entry) => !entry.isDirectory)
.map((entry) => entry.entryName);
}
case "read": {
const filePath = stringValue(args.filePath, "filePath");
const entryName = stringValue(args.entryName, "entryName");
const entry = new AdmZip(filePath)
.getEntries()
.find((e) => e.entryName === entryName);
return entry && !entry.isDirectory ? entry.getData() : null;
}
case "create": {
const outputPath = stringValue(args.outputPath, "outputPath");
if (!Array.isArray(args.entries)) {
throw new TypeError("entries must be an array");
}
const zip = new AdmZip();
for (const entry of args.entries) {
if (!entry || typeof entry.name !== "string") {
throw new TypeError("Invalid zip entry");
}
zip.addFile(entry.name, Buffer.from(normalizeFileData(entry.data)));
}
zip.writeZip(outputPath);
return true;
}
default:
throw new Error(`Unsupported zip operation: ${args.operation}`);
}
});
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");
}
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) => {
const { bookTitle, author, percentage } = config;
if (!discordRPCReady) {
await initDiscordRPC();
}
if (!discordRPCClient || !discordRPCReady) return;
try {
const progressBar = buildProgressBar(percentage);
await discordRPCClient.setActivity({
details: bookTitle,
state: `${progressBar} ${percentage}% | by ${author}`,
largeImageKey: "koodo_reader_logo",
largeImageText: "Koodo Reader",
startTimestamp: Date.now(),
instance: false,
buttons: [
{
label: "Get Koodo Reader",
url: "https://koodoreader.com",
},
],
});
} catch (e) {
console.warn("Failed to set Discord activity:", e.message);
}
});
ipcMain.handle("discord-rpc-clear", async (event) => {
if (discordRPCClient) {
try {
await discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
});
ipcMain.handle("update-win-app", (event, config) => {
let fileName = `koodo-reader-installer.exe`;
let supportedArchs = ["x64", "ia32", "arm64"];
//get system arch
let arch = os.arch();
if (!supportedArchs.includes(arch)) {
return;
}
let url = `https://dl.koodoreader.com/v${config.version}/Koodo-Reader-${config.version}-${arch}.exe`;
const https = require("https");
const { spawn } = require("child_process");
const file = fs.createWriteStream(path.join(app.getPath("temp"), fileName));
downloadRequest = https.get(url, (res) => {
const totalSize = parseInt(res.headers["content-length"], 10);
let downloadedSize = 0;
res.on("data", (chunk) => {
downloadedSize += chunk.length;
const progress = ((downloadedSize / totalSize) * 100).toFixed(2);
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
mainWin.webContents.send("download-app-progress", {
progress,
downloadedMB,
totalMB,
});
});
res.pipe(file);
file.on("finish", () => {
console.info("\n下载完成!");
file.close();
let updateExePath = path.join(app.getPath("temp"), fileName);
if (!fs.existsSync(updateExePath)) {
console.error("更新包不存在:", updateExePath);
return;
}
// 验证文件可执行性
try {
fs.accessSync(updateExePath, fs.constants.X_OK);
console.info("更新包可执行性验证通过");
} catch (err) {
console.error("更新包不可执行:", err.message);
return;
}
try {
// 先退出应用,再启动安装程序,避免文件锁定导致覆盖安装失败
app.once("will-quit", () => {
const child = spawn(updateExePath, [], {
stdio: "ignore",
detached: true,
shell: true,
windowsHide: false,
});
child.unref();
});
app.quit();
} catch (err) {
console.error(`spawn 执行异常: ${err.message}`);
}
});
});
});
ipcMain.handle("open-book", (event, config) => {
let { url, isMergeWord, isAutoFullscreen, isAutoMaximize, isPreventSleep } =
config;
if (isMergeWord) {
delete options.backgroundColor;
}
store.set({
url,
isMergeWord: isMergeWord || "no",
isAutoFullscreen: isAutoFullscreen || "no",
isAutoMaximize: isAutoMaximize || "no",
isPreventSleep: isPreventSleep || "no",
});
let id;
if (isPreventSleep === "yes") {
id = powerSaveBlocker.start("prevent-display-sleep");
console.info(powerSaveBlocker.isStarted(id));
}
if (readerWindow) {
readerWindowList.push(readerWindow);
}
if (isAutoFullscreen === "yes" || isAutoMaximize === "yes") {
readerWindow = new BrowserWindow(options);
readerWindow.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", {});
}
if (discordRPCClient) {
try {
discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
});
// Renderer finished flushing reading-time data — proceed with actual close
ipcMain.once("reader-close-ready", () => {
if (readerWindow && !readerWindow.isDestroyed()) {
readerWindowReadyToClose = true;
readerWindow.close();
}
});
event.returnValue = "success";
});
ipcMain.handle("ai-request", async (event, payload) => {
const { url, method, headers, body } = payload || {};
const response = await net.fetch(url, {
method,
headers: headers || undefined,
body: method === "POST" ? body : undefined,
});
const responseText = await response.text();
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: responseText,
};
});
ipcMain.handle("generate-tts", async (event, voiceConfig) => {
const { text, speed, pluginKey, config } = voiceConfig || {};
const plugin = getVoicePlugin(pluginKey);
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);
if (
!plugin ||
typeof plugin.getTTSVoice !== "function" ||
!config ||
typeof config !== "object" ||
Array.isArray(config)
) {
throw new Error("Invalid TTS voice request");
}
const voices = await plugin.getTTSVoice(config);
if (!Array.isArray(voices)) {
throw new Error("Invalid TTS voice list");
}
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 https = require("https");
const http = require("http");
const { URL } = require("url");
const { url } = config;
return new Promise((resolve) => {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch (e) {
return resolve({ ok: false, reason: "invalid_url", detail: e.message });
}
const isHttps = parsedUrl.protocol === "https:";
const lib = isHttps ? https : http;
const port = parsedUrl.port
? parseInt(parsedUrl.port)
: isHttps
? 443
: 80;
const options = {
hostname: parsedUrl.hostname,
port,
path: parsedUrl.pathname || "/",
method: "HEAD",
timeout: 8000,
rejectUnauthorized: true,
};
const req = lib.request(options, (res) => {
resolve({
ok: true,
status: res.statusCode,
detail: `HTTP ${res.statusCode}`,
});
});
req.on("timeout", () => {
req.destroy();
resolve({
ok: false,
reason: "timeout",
detail: `Connection to ${parsedUrl.hostname}:${port} timed out after 8s`,
});
});
req.on("error", (err) => {
let reason = "unknown";
if (err.code === "ENOTFOUND") {
reason = "dns_failed";
} else if (err.code === "ECONNREFUSED") {
reason = "connection_refused";
} else if (err.code === "ECONNRESET") {
reason = "connection_reset";
} else if (err.code === "ETIMEDOUT") {
reason = "timeout";
} else if (
err.code === "CERT_HAS_EXPIRED" ||
err.code === "ERR_TLS_CERT_ALTNAME_INVALID" ||
err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
) {
reason = "ssl_error";
} else if (err.message && err.message.includes("SSL")) {
reason = "ssl_error";
}
resolve({
ok: false,
reason,
code: err.code || "",
detail: err.message,
});
});
req.end();
});
});
ipcMain.handle("get-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) => {
const https = require("https");
const { URL } = require("url");
const proxyConfig = config || {};
const validTypes = ["http", "socks5"];
if (!validTypes.includes(proxyConfig.type)) {
return { ok: false, reason: "invalid_input", detail: "unsupported type" };
}
if (
!proxyConfig.host ||
typeof proxyConfig.host !== "string" ||
proxyConfig.host.includes("://")
) {
return { ok: false, reason: "invalid_input", detail: "invalid host" };
}
const portNumber = parseInt(proxyConfig.port);
if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {
return { ok: false, reason: "invalid_input", detail: "invalid port" };
}
const proxyUrl = buildProxyUrl(proxyConfig);
let agent;
try {
if (proxyConfig.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) {
return { ok: false, reason: "agent_init_failed", detail: error.message };
}
const target = new URL("https://www.google.com/");
const startTime = Date.now();
return new Promise((resolve) => {
const options = {
hostname: target.hostname,
port: 443,
path: "/",
method: "HEAD",
timeout: 10000,
agent,
rejectUnauthorized: true,
};
const request = https.request(options, (response) => {
const elapsedMs = Date.now() - startTime;
if (response.statusCode === 407) {
return resolve({
ok: false,
reason: "proxy_auth_failed",
status: 407,
elapsedMs,
detail: `HTTP 407 Proxy Authentication Required`,
});
}
resolve({
ok:
response.statusCode &&
response.statusCode >= 200 &&
response.statusCode < 400,
status: response.statusCode,
elapsedMs,
detail: `HTTP ${response.statusCode}`,
});
});
request.on("timeout", () => {
request.destroy();
resolve({
ok: false,
reason: "timeout",
elapsedMs: Date.now() - startTime,
detail: `Connection to ${target.hostname} timed out after 10s`,
});
});
request.on("error", (error) => {
let reason = "unknown";
if (error.code === "ENOTFOUND") {
reason = "dns_failed";
} else if (error.code === "ECONNREFUSED") {
reason = "connection_refused";
} else if (error.code === "ECONNRESET") {
reason = "connection_reset";
} else if (error.code === "ETIMEDOUT") {
reason = "timeout";
} else if (error.code === "EPROTO") {
reason = "ssl_error";
} else if (
error.code === "CERT_HAS_EXPIRED" ||
error.code === "ERR_TLS_CERT_ALTNAME_INVALID" ||
error.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
) {
reason = "ssl_error";
} else if (error.message && error.message.includes("SSL")) {
reason = "ssl_error";
}
resolve({
ok: false,
reason,
code: error.code || "",
elapsedMs: Date.now() - startTime,
detail: error.message,
});
});
request.end();
});
});
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-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) => {
if (!config || typeof config !== "object") {
throw new TypeError("Invalid backup config");
}
const { targetPath, fileName, dataPath, dirs, files } = config;
if (
[targetPath, dataPath].some((v) => typeof v !== "string" || !v) ||
typeof fileName !== "string" ||
!fileName ||
!Array.isArray(dirs) ||
!Array.isArray(files)
) {
throw new TypeError("Invalid backup arguments");
}
const sendProgress = (percent) => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("backup-progress", { percent });
}
};
// 校验 dirs/files 路径均位于 dataPath 之内,防止路径穿越
const base = path.resolve(dataPath);
const assertInside = (p) => {
const resolved = path.resolve(p);
const rel = path.relative(base, resolved);
if (
rel.startsWith(".." + path.sep) ||
path.isAbsolute(rel) ||
rel.split(path.sep).includes("..")
) {
throw new Error("Backup source path is outside the data directory");
}
return resolved;
};
try {
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true });
}
const destinationPath = path.join(targetPath, fileName);
const tempPath = destinationPath + ".tmp";
await new Promise((resolve, reject) => {
const zip = new yazl.ZipFile();
zip.level = 6;
const output = fs.createWriteStream(tempPath);
let totalBytes = 0;
let writtenBytes = 0;
// 条目源文件由 yazl 内部以 createReadStream 读取,读写出错时 yazl 虽会
// 触发 emit("error"),但 outputStream 的 "end" 不再走到,导致 promise
// 永久悬挂、进度 toast 卡死。统一收集错误:完成后销毁输出流再 reject,
// 主进程 catch 会清理 .tmp 并向渲染进程返回失败。
const errored = (err) => {
try {
output.destroy();
} catch (_) {}
reject(err);
};
output.on("error", errored);
zip.outputStream.on("error", errored);
zip.on("error", errored);
const finish = () => {
output.end();
};
output.on("close", resolve);
// 列出所有待打包的源文件并累计总字节数(用于进度估算)
const entries = [];
const collect = (zipDir, sourceDir) => {
let direntNames;
try {
direntNames = fs.readdirSync(sourceDir, { withFileTypes: true });
} catch (_) {
return;
}
if (direntNames.length === 0) return;
for (const entry of direntNames) {
const sourcePath = path.join(sourceDir, entry.name);
const entryZip = path.posix.join(zipDir, entry.name);
if (entry.isDirectory()) {
collect(entryZip, sourcePath);
} else if (entry.isFile()) {
try {
totalBytes += fs.statSync(sourcePath).size;
} catch (_) {}
entries.push({ sourcePath, entryZip });
}
}
};
for (const dir of dirs) {
const sourceDir = assertInside(path.join(dataPath, dir));
if (fs.existsSync(sourceDir)) {
zip.addEmptyDirectory(dir);
collect(dir, sourceDir);
}
}
for (const filePath of files) {
const sourcePath = assertInside(
path.resolve(dataPath, filePath.replace(/^[/\\]/, ""))
);
if (fs.existsSync(sourcePath)) {
try {
totalBytes += fs.statSync(sourcePath).size;
} catch (_) {}
entries.push({
sourcePath,
entryZip: path.posix.normalize(filePath.replace(/^[/\\]/, "")),
});
}
}
for (const entry of entries) {
zip.addFile(entry.sourcePath, entry.entryZip);
}
zip.end();
zip.outputStream.on("data", (chunk) => {
writtenBytes += chunk.length;
});
zip.outputStream.on("end", () => {
finish();
});
// 进度估算:zip.outputStream 无内建进度,按“已写入条目的源字节”
// 与总字节数的比例上报(压缩前后差异不影响 UI 展示)
zip.outputStream.pipe(output);
const report = setInterval(() => {
const percent = totalBytes
? Math.min(100, Math.round((writtenBytes / totalBytes) * 100))
: 100;
sendProgress(percent);
}, 100);
zip.outputStream.on("end", () => {
clearInterval(report);
});
});
let tempStat;
try {
tempStat = fs.statSync(tempPath);
} catch (_) {
throw new Error("Backup output file was not created");
}
if (fs.existsSync(destinationPath)) {
fs.unlinkSync(destinationPath);
}
fs.renameSync(tempPath, destinationPath);
sendProgress(100);
return { ok: true, size: tempStat.size };
} catch (error) {
try {
const tempPath = path.join(targetPath, fileName) + ".tmp";
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (_) {}
const message = error instanceof Error ? error.message : String(error);
console.error("backup-path failed:", message);
return { ok: false, error: message };
}
});
// 流式解压恢复:用 yauzl 逐条目 openReadStream,避免把整个 zip 读入内存。
// 资产文件(book/cover/dict/background/font/snapshot)在主进程直接流式写盘,
// config 类文件(config/*.db、config.json、sync.json)回传渲染进程处理:
// .db 经 sql.js 解析后与本地记录合并写入,json 写入 ConfigService。
ipcMain.handle("restore-path", async (event, config) => {
if (!config || typeof config !== "object") {
throw new TypeError("Invalid restore config");
}
const { filePath, dataPath } = config;
if (
typeof filePath !== "string" ||
!filePath ||
typeof dataPath !== "string" ||
!dataPath
) {
throw new TypeError("Invalid restore arguments");
}
if (!fs.existsSync(filePath)) {
return { ok: false, error: "Backup file not found" };
}
const sendProgress = (percent) => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("restore-progress", { percent });
}
};
const base = path.resolve(dataPath);
const assertInside = (p) => {
const resolved = path.resolve(p);
const rel = path.relative(base, resolved);
if (
rel.startsWith(".." + path.sep) ||
path.isAbsolute(rel) ||
rel.split(path.sep).includes("..")
) {
throw new Error(
"Restore destination path is outside the data directory"
);
}
return resolved;
};
const ASSET_PREFIXES = [
"book/",
"cover/",
"dict/",
"background/",
"font/",
"snapshot/",
];
const isConfigFile = (name) => {
if (!name.startsWith("config/")) return false;
const rest = name.slice("config/".length);
if (rest.includes("/")) return false;
return (
rest.endsWith(".db") || rest === "config.json" || rest === "sync.json"
);
};
const isAssetFile = (name) =>
ASSET_PREFIXES.some((prefix) => name.startsWith(prefix));
const isFileEntry = (entry) => !/\/$/.test(entry.fileName);
// 用 yauzl 回调式 API 遍历(其 eachEntry() 迭代器与 FdSlicer 的 ref/unref
// 时序存在冲突,遍历结束后 fd 会被提前关闭,故不使用 for await 形式)。
const scanEntries = () =>
new Promise((resolve, reject) => {
yauzl.fromFd(
fs.openSync(filePath, "r"),
{ lazyEntries: true, autoClose: true },
(err, zf) => {
if (err) return reject(err);
let hasNewConfig = false;
let totalEntries = 0;
zf.on("entry", (entry) => {
if (entry.fileName === "config/config.json") hasNewConfig = true;
totalEntries++;
zf.readEntry();
});
zf.on("end", () => resolve({ hasNewConfig, totalEntries }));
zf.on("error", reject);
zf.readEntry();
}
);
});
let scanResult;
try {
scanResult = await scanEntries();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: message };
}
if (!scanResult.hasNewConfig) {
return { ok: false, isNewBackup: false };
}
const totalEntries = scanResult.totalEntries;
// 第二遍:逐条目 openReadStream 流式处理。
const configBuffers = [];
let processed = 0;
const processAllEntries = () =>
new Promise((resolve, reject) => {
yauzl.fromFd(
fs.openSync(filePath, "r"),
{ lazyEntries: true, autoClose: true },
(err, zf) => {
if (err) return reject(err);
const advance = () => {
processed++;
sendProgress(
Math.round((processed / Math.max(totalEntries, 1)) * 100)
);
zf.readEntry();
};
zf.on("entry", (entry) => {
const name = entry.fileName;
if (!isFileEntry(entry)) {
// 目录条目:在主进程创建对应目录。若目标路径被一个异常空文件占住
// (历史残留),先删除该文件再建目录,避免后续写入 ENOENT。
if (isAssetFile(name) || name.startsWith("config/")) {
let dirDest;
try {
dirDest = assertInside(
path.join(dataPath, name.replace(/\/$/, ""))
);
} catch (e3) {
return reject(e3);
}
try {
if (
fs.existsSync(dirDest) &&
!fs.statSync(dirDest).isDirectory()
) {
fs.unlinkSync(dirDest);
}
fs.mkdirSync(dirDest, { recursive: true });
} catch (e3) {
return reject(e3);
}
}
advance();
return;
}
zf.openReadStream(entry, (e2, readStream) => {
if (e2) return reject(e2);
if (isAssetFile(name)) {
// 流式写盘,不进内存
let destination;
try {
destination = assertInside(path.join(dataPath, name));
} catch (e3) {
return reject(e3);
}
const directory = path.dirname(destination);
try {
if (
fs.existsSync(directory) &&
!fs.statSync(directory).isDirectory()
) {
fs.unlinkSync(directory);
}
fs.mkdirSync(directory, { recursive: true });
} catch (e3) {
return reject(e3);
}
const output = fs.createWriteStream(destination);
output.on("error", reject);
readStream.on("error", reject);
output.on("close", advance);
readStream.pipe(output);
} else if (isConfigFile(name)) {
// config 类文件(*.db / config.json / sync.json)累积为 Buffer
// 回传渲染进程处理:.db 经 sql.js 解析合并,json 写入 ConfigService
const chunks = [];
readStream.on("data", (chunk) => chunks.push(chunk));
readStream.on("error", reject);
readStream.on("end", () => {
const buf = Buffer.concat(chunks);
configBuffers.push({
name,
buffer: buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength
),
});
advance();
});
} else {
readStream.resume();
readStream.on("end", advance);
readStream.on("error", reject);
}
});
});
zf.on("end", resolve);
zf.on("error", reject);
zf.readEntry();
}
);
});
try {
await processAllEntries();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("restore-path failed:", message);
return { ok: false, error: message };
}
sendProgress(100);
return {
ok: true,
isNewBackup: true,
configFiles: configBuffers,
};
});
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.contentView.removeChildView(mainView);
}
if (discordRPCClient) {
try {
discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
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("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", {});
}
if (discordRPCClient) {
try {
discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
});
// Renderer finished flushing reading-time data — proceed with actual close
ipcMain.once("reader-close-ready", () => {
if (readerWindow && !readerWindow.isDestroyed()) {
readerWindowReadyToClose = true;
readerWindow.close();
}
});
}
event.returnvalue = false;
});
ipcMain.on("storage-location", (event, config) => {
event.returnValue = path.join(dirPath, "data");
});
ipcMain.on("url-window-status", (event, config) => {
if (config.type === "dict") {
event.returnValue =
dictWindow && !dictWindow.isDestroyed() ? true : false;
} else if (config.type === "trans") {
event.returnValue =
transWindow && !transWindow.isDestroyed() ? true : false;
} else {
event.returnValue =
linkWindow && !linkWindow.isDestroyed() ? true : false;
}
});
ipcMain.on("get-dirname", (event, arg) => {
event.returnValue = __dirname;
});
ipcMain.on("system-color", (event, arg) => {
event.returnValue = getNativeDarkColorStatus() || false;
});
ipcMain.handle("set-native-theme-source", (event, appSkin) => {
return applyNativeThemeSource(appSkin);
});
ipcMain.on("get-file-data", function (event) {
if (fs.existsSync(path.join(dirPath, "log.json"))) {
try {
const _data = JSON.parse(
fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}"
);
if (_data && _data.filePath) {
filePath = _data.filePath;
setTimeout(() => {
fs.writeFileSync(path.join(dirPath, "log.json"), "{}", "utf-8");
}, 1000);
}
} catch (error) {
console.error("Error reading log.json:", error);
}
}
event.returnValue = filePath;
filePath = null;
});
ipcMain.on("check-file-data", function (event) {
if (fs.existsSync(path.join(dirPath, "log.json"))) {
try {
const _data = JSON.parse(
fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}"
);
if (_data && _data.filePath) {
filePath = _data.filePath;
}
} catch (error) {
console.error("Error reading log.json:", error);
}
}
event.returnValue = filePath;
filePath = null;
});
ipcMain.handle("system-ocr", async (event, config) => {
const { base64, lang } = config || {};
let tempPath = null;
try {
const { buffer, ext } = parseOcrImageInput(base64);
tempPath = writeOcrTempImage(buffer, ext);
const { macos, win } = resolveOcrLang(lang);
let text = "";
if (process.platform === "darwin") {
text = await runMacosOcr(tempPath, macos);
} else if (process.platform === "win32") {
text = await runWindowsOcr(tempPath, win);
} else {
return {
ok: false,
error: "System OCR is only supported on Windows and macOS",
};
}
return { ok: true, text };
} catch (error) {
log.error("system-ocr failed:", error.message);
return { ok: false, error: error.message || "OCR failed" };
} finally {
if (tempPath) {
try {
fs.unlinkSync(tempPath);
} catch (e) {
// 忽略清理失败
}
}
}
});
};
const applyCorsToRendererRequests = () => {
const filter = {
urls: ["http://*/*", "https://*/*"],
};
session.defaultSession.webRequest.onHeadersReceived(
filter,
(details, callback) => {
const responseHeaders = { ...details.responseHeaders };
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods":
"GET, POST, PUT, DELETE, OPTIONS, PATCH",
"Access-Control-Allow-Headers": "*, Authorization",
"Access-Control-Expose-Headers": "*",
};
if (details.method === "OPTIONS") {
corsHeaders["Access-Control-Max-Age"] = "86400";
}
for (const [name, value] of Object.entries(corsHeaders)) {
for (const existingName of Object.keys(responseHeaders)) {
if (existingName.toLowerCase() === name.toLowerCase()) {
delete responseHeaders[existingName];
}
}
responseHeaders[name] = [value];
}
callback({ responseHeaders });
}
);
};
app.on("ready", async () => {
applyCorsToRendererRequests();
await applyProxyToSession();
createMainWin();
});
app.on("before-quit", () => {
isQuitting = true;
destroyDiscordRPC();
});
app.on("window-all-closed", () => {
app.quit();
});
app.on("open-file", (e, pathToFile) => {
filePath = pathToFile;
});
// Register protocol handler
app.setAsDefaultProtocolClient("koodo-reader");
const serializeArg = (arg) => {
if (arg === null) return "null";
if (arg === undefined) return "undefined";
if (typeof arg === "object") {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
return String(arg);
};
const originalConsoleLog = console.log;
console.log = function (...args) {
originalConsoleLog(...args); // 保留原日志
log.info(args.map(serializeArg).join(" ")); // 写入日志文件
};
const originalConsoleError = console.error;
console.error = function (...args) {
originalConsoleError(...args); // 保留原错误日志
log.error(args.map(serializeArg).join(" ")); // 写入错误日志文件
};
const originalConsoleWarn = console.warn;
console.warn = function (...args) {
originalConsoleWarn(...args); // 保留原警告日志
log.warn(args.map(serializeArg).join(" ")); // 写入警告日志文件
};
const originalConsoleInfo = console.info;
console.info = function (...args) {
originalConsoleInfo(...args); // 保留原信息日志
log.info(args.map(serializeArg).join(" ")); // 写入信息日志文件
};
// Handle MacOS deep linking
app.on("open-url", (event, url) => {
event.preventDefault();
handleCallback(url);
});
const handleCallback = (url) => {
try {
// 检查 URL 是否有效
if (!url.startsWith("koodo-reader://")) {
console.error("Invalid URL format:", url);
return;
}
// 解析 URL
const parsedUrl = new URL(url);
const code = parsedUrl.searchParams.get("code");
const state = parsedUrl.searchParams.get("state");
const pickerData = parsedUrl.searchParams.get("pickerData");
const bookKey = parsedUrl.searchParams.get("bookKey");
const noteKey = parsedUrl.searchParams.get("noteKey");
const importUrl = parsedUrl.searchParams.get("importUrl");
if (code && mainWin) {
mainWin.webContents.send("oauth-callback", { code, state });
}
if (pickerData && mainWin) {
let config = JSON.parse(decodeURIComponent(pickerData));
mainWin.webContents.send("picker-finished", config);
}
if (bookKey && mainWin) {
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("open-book-from-link", { bookKey });
}
if (noteKey && mainWin) {
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("open-note-from-link", { noteKey });
}
if (importUrl && mainWin) {
const decodedUrl = decodeURIComponent(importUrl);
if (mainWin.isMinimized()) mainWin.restore();
mainWin.show();
mainWin.focus();
mainWin.webContents.send("import-url-from-link", { url: decodedUrl });
}
} catch (error) {
console.error("Error handling callback URL:", error);
console.info("Problematic URL:", url);
}
};