Files
koodo-reader/main.js
T

2116 lines
66 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,
protocol,
screen,
systemPreferences,
} = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");
const Store = require("electron-store");
const log = require("electron-log/main");
const os = require("os");
const { execFile } = require("child_process");
const store = new Store();
const fs = require("fs");
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 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: false,
nodeIntegration: true,
contextIsolation: false,
nativeWindowOpen: true,
nodeIntegrationInSubFrames: false,
allowRunningInsecureContent: false,
enableRemoteModule: true,
sandbox: false,
},
};
const Database = require("better-sqlite3");
if (os.platform() === "linux") {
options = Object.assign({}, options, {
icon: path.join(__dirname, "./build/assets/icon.png"),
});
}
// Single Instance Lock
if (!singleInstance) {
app.quit();
} else {
app.on("second-instance", (event, argv, workingDir) => {
if (mainWin) {
if (!mainWin.isVisible()) mainWin.show();
mainWin.focus();
}
// 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"));
// 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, line, sourceId) => {
console.log(`[Renderer Console] Message: ${message}`);
}
);
//cancel-download-app
ipcMain.handle("cancel-download-app", (event, arg) => {
// Implement cancellation logic here
// Note: In this example, we are not keeping a reference to the request,
// so we cannot actually abort it. This is a placeholder for demonstration.
if (downloadRequest) {
downloadRequest.abort();
downloadRequest = null;
}
event.returnValue = "cancelled";
});
// Discord RPC handlers
ipcMain.handle("discord-rpc-update", async (event, config) => {
const { bookTitle, author, percentage } = config;
if (!discordRPCReady) {
await initDiscordRPC();
}
if (!discordRPCClient || !discordRPCReady) return;
try {
const progressBar = buildProgressBar(percentage);
await discordRPCClient.setActivity({
details: bookTitle,
state: `${progressBar} ${percentage}% | by ${author}`,
largeImageKey: "koodo_reader_logo",
largeImageText: "Koodo Reader",
startTimestamp: Date.now(),
instance: false,
buttons: [
{
label: "Get Koodo Reader",
url: "https://koodoreader.com",
},
],
});
} catch (e) {
console.warn("Failed to set Discord activity:", e.message);
}
});
ipcMain.handle("discord-rpc-clear", async (event) => {
if (discordRPCClient) {
try {
await discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
});
ipcMain.handle("update-win-app", (event, config) => {
let fileName = `koodo-reader-installer.exe`;
let supportedArchs = ["x64", "ia32", "arm64"];
//get system arch
let arch = os.arch();
if (!supportedArchs.includes(arch)) {
return;
}
let url = `https://dl.koodoreader.com/v${config.version}/Koodo-Reader-${config.version}-${arch}.exe`;
const https = require("https");
const { spawn } = require("child_process");
const file = fs.createWriteStream(path.join(app.getPath("temp"), fileName));
downloadRequest = https.get(url, (res) => {
const totalSize = parseInt(res.headers["content-length"], 10);
let downloadedSize = 0;
res.on("data", (chunk) => {
downloadedSize += chunk.length;
const progress = ((downloadedSize / totalSize) * 100).toFixed(2);
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
mainWin.webContents.send("download-app-progress", {
progress,
downloadedMB,
totalMB,
});
});
res.pipe(file);
file.on("finish", () => {
console.info("\n下载完成!");
file.close();
let updateExePath = path.join(app.getPath("temp"), fileName);
if (!fs.existsSync(updateExePath)) {
console.error("更新包不存在:", updateExePath);
return;
}
// 验证文件可执行性
try {
fs.accessSync(updateExePath, fs.constants.X_OK);
console.info("更新包可执行性验证通过");
} catch (err) {
console.error("更新包不可执行:", err.message);
return;
}
try {
// 先退出应用,再启动安装程序,避免文件锁定导致覆盖安装失败
app.once("will-quit", () => {
const child = spawn(updateExePath, [], {
stdio: "ignore",
detached: true,
shell: true,
windowsHide: false,
});
child.unref();
});
app.quit();
} catch (err) {
console.error(`spawn 执行异常: ${err.message}`);
}
});
});
});
ipcMain.handle("open-book", (event, config) => {
let { url, isMergeWord, isAutoFullscreen, isAutoMaximize, isPreventSleep } =
config;
if (isMergeWord) {
delete options.backgroundColor;
}
store.set({
url,
isMergeWord: isMergeWord || "no",
isAutoFullscreen: isAutoFullscreen || "no",
isAutoMaximize: isAutoMaximize || "no",
isPreventSleep: isPreventSleep || "no",
});
let id;
if (isPreventSleep === "yes") {
id = powerSaveBlocker.start("prevent-display-sleep");
console.info(powerSaveBlocker.isStarted(id));
}
if (readerWindow) {
readerWindowList.push(readerWindow);
}
if (isAutoFullscreen === "yes" || isAutoMaximize === "yes") {
readerWindow = new BrowserWindow(options);
readerWindow.loadURL(url);
if (isAutoFullscreen === "yes") {
readerWindow.setFullScreen(true);
} else if (isAutoMaximize === "yes") {
readerWindow.maximize();
}
} else {
const scaleRatio = store.get("windowDisplayScale") || 1;
const isWindowVisible = isWindowPartiallyVisible({
x: parseInt(store.get("windowX")),
y: parseInt(store.get("windowY")),
width: parseInt(store.get("windowWidth") || 1050) / scaleRatio,
height: parseInt(store.get("windowHeight") || 660) / scaleRatio,
});
readerWindow = new BrowserWindow({
...options,
width: parseInt(store.get("windowWidth") || 1050) / scaleRatio,
height: parseInt(store.get("windowHeight") || 660) / scaleRatio,
x: isWindowVisible ? parseInt(store.get("windowX")) : undefined,
y: isWindowVisible ? parseInt(store.get("windowY")) : undefined,
frame: isMergeWord === "yes" ? false : true,
hasShadow: isMergeWord === "yes" ? false : true,
transparent: isMergeWord === "yes" ? true : false,
});
readerWindow.loadURL(url);
// readerWindow.webContents.openDevTools();
}
if (store.get("isAlwaysOnTop") === "yes") {
readerWindow.setAlwaysOnTop(true);
}
readerWindowReadyToClose = false;
readerWindow.on("close", (event) => {
// --- Step 1: ask renderer to flush reading-time data first ---
if (
!readerWindowReadyToClose &&
readerWindow &&
!readerWindow.isDestroyed()
) {
event.preventDefault();
readerWindow.webContents.send("before-reader-close");
return;
}
// --- Step 2: actual close logic (reached after renderer replied) ---
if (readerWindow && !readerWindow.isDestroyed()) {
let bounds = readerWindow.getBounds();
const currentDisplay = screen.getDisplayMatching(bounds);
const primaryDisplay = screen.getPrimaryDisplay();
if (bounds.width > 300 && bounds.height > 100) {
store.set({
windowWidth: bounds.width,
windowHeight: bounds.height,
windowX:
readerWindow.isMaximized() &&
currentDisplay.id === primaryDisplay.id
? 0
: bounds.x,
windowY:
readerWindow.isMaximized() &&
currentDisplay.id === primaryDisplay.id
? 0
: bounds.y < 0
? 0
: bounds.y,
windowDisplayScale:
currentDisplay.scaleFactor / primaryDisplay.scaleFactor,
});
}
}
if (isPreventSleep && !readerWindow.isDestroyed()) {
id && powerSaveBlocker.stop(id);
}
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("reading-finished", {});
}
if (discordRPCClient) {
try {
discordRPCClient.clearActivity();
} catch (e) {
console.warn("Failed to clear Discord activity:", e.message);
}
}
});
// Renderer finished flushing reading-time data — proceed with actual close
ipcMain.once("reader-close-ready", () => {
if (readerWindow && !readerWindow.isDestroyed()) {
readerWindowReadyToClose = true;
readerWindow.close();
}
});
event.returnValue = "success";
});
ipcMain.handle("generate-tts", async (event, voiceConfig) => {
let { text, speed, plugin, config } = voiceConfig;
let voiceFunc = plugin.script;
// eslint-disable-next-line no-eval
eval(voiceFunc);
return global.getAudioPath(text, speed, dirPath, config);
});
ipcMain.handle("cloud-upload", async (event, config) => {
let syncUtil = await getSyncUtil(config, config.isUseCache);
let result = await syncUtil.uploadFile(
config.fileName,
config.fileName,
config.type
);
return result;
});
ipcMain.handle("cloud-download", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = await syncUtil.downloadFile(
config.fileName,
(config.isTemp ? "temp-" : "") + config.fileName,
config.type
);
return result;
});
ipcMain.handle("cloud-progress", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = syncUtil.getDownloadedSize();
return result;
});
ipcMain.handle("picker-download", async (event, config) => {
let pickerUtil = await getPickerUtil(config);
let result = await pickerUtil.remote.downloadFile(
config.sourcePath,
config.destPath
);
return result;
});
ipcMain.handle("picker-progress", async (event, config) => {
let pickerUtil = await getPickerUtil(config);
let result = await pickerUtil.getDownloadedSize();
return result;
});
ipcMain.handle("cloud-reset", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = syncUtil.resetCounters();
return result;
});
ipcMain.handle("cloud-stats", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = syncUtil.getStats();
return result;
});
ipcMain.handle("cloud-delete", async (event, config) => {
try {
let syncUtil = await getSyncUtil(config, config.isUseCache);
let result = await syncUtil.deleteFile(config.fileName, config.type);
return result;
} catch (error) {
console.error("Error deleting file:", error);
}
return false;
});
ipcMain.handle("cloud-list", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = await syncUtil.listFiles(config.type);
return result;
});
ipcMain.handle("picker-list", async (event, config) => {
let pickerUtil = await getPickerUtil(config);
let result = await pickerUtil.listFileInfos(config.currentPath);
return result;
});
ipcMain.handle("cloud-exist", async (event, config) => {
let syncUtil = await getSyncUtil(config);
let result = await syncUtil.isExist(config.fileName, config.type);
return result;
});
ipcMain.handle("cloud-close", async (event, config) => {
removeSyncUtil(config);
return "pong";
});
ipcMain.handle("clear-tts", async (event, config) => {
if (!fs.existsSync(path.join(dirPath, "tts"))) {
return "pong";
} else {
const fsExtra = require("fs-extra");
try {
await fsExtra.remove(path.join(dirPath, "tts"));
await fsExtra.mkdir(path.join(dirPath, "tts"));
return "pong";
} catch (err) {
console.error(err);
return "pong";
}
}
});
ipcMain.handle("select-path", async (event) => {
var path = await dialog.showOpenDialog({
properties: ["openDirectory"],
});
return path.filePaths[0];
});
ipcMain.handle("select-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) => {
const { TokenService } =
await import("./src/assets/lib/kookit-extra.min.mjs");
let fingerprint = await TokenService.getFingerprint();
let encrypted = encrypt(config.token, fingerprint);
store.set("encryptedToken", encrypted);
return "pong";
});
ipcMain.handle("decrypt-data", async (event) => {
let encrypted = store.get("encryptedToken");
if (!encrypted) return "";
const { TokenService } =
await import("./src/assets/lib/kookit-extra.min.mjs");
let fingerprint = await TokenService.getFingerprint();
let decrypted = decrypt(encrypted, fingerprint);
if (decrypted.startsWith("{") && decrypted.endsWith("}")) {
return decrypted;
} else {
try {
const { safeStorage } = require("electron");
decrypted = safeStorage.decryptString(Buffer.from(encrypted, "base64"));
let newEncrypted = encrypt(decrypted, fingerprint);
store.set("encryptedToken", newEncrypted);
return decrypted;
} catch (error) {
console.error("Decryption failed:", error);
return "{}";
}
}
});
ipcMain.handle("check-cloud-url", async (event, config) => {
const https = require("https");
const http = require("http");
const { URL } = require("url");
const { url } = config;
return new Promise((resolve) => {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch (e) {
return resolve({ ok: false, reason: "invalid_url", detail: e.message });
}
const isHttps = parsedUrl.protocol === "https:";
const lib = isHttps ? https : http;
const port = parsedUrl.port
? parseInt(parsedUrl.port)
: isHttps
? 443
: 80;
const options = {
hostname: parsedUrl.hostname,
port,
path: parsedUrl.pathname || "/",
method: "HEAD",
timeout: 8000,
rejectUnauthorized: true,
};
const req = lib.request(options, (res) => {
resolve({
ok: true,
status: res.statusCode,
detail: `HTTP ${res.statusCode}`,
});
});
req.on("timeout", () => {
req.destroy();
resolve({
ok: false,
reason: "timeout",
detail: `Connection to ${parsedUrl.hostname}:${port} timed out after 8s`,
});
});
req.on("error", (err) => {
let reason = "unknown";
if (err.code === "ENOTFOUND") {
reason = "dns_failed";
} else if (err.code === "ECONNREFUSED") {
reason = "connection_refused";
} else if (err.code === "ECONNRESET") {
reason = "connection_reset";
} else if (err.code === "ETIMEDOUT") {
reason = "timeout";
} else if (
err.code === "CERT_HAS_EXPIRED" ||
err.code === "ERR_TLS_CERT_ALTNAME_INVALID" ||
err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
) {
reason = "ssl_error";
} else if (err.message && err.message.includes("SSL")) {
reason = "ssl_error";
}
resolve({
ok: false,
reason,
code: err.code || "",
detail: err.message,
});
});
req.end();
});
});
ipcMain.handle("get-mac", async (event, config) => {
const { machineIdSync } = require("node-machine-id");
return machineIdSync();
});
ipcMain.handle("get-device-name", async () => {
return os.hostname() || "";
});
ipcMain.handle("get-store-value", async (event, config) => {
return store.get(config.key);
});
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();
});
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, "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);
}
});
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.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("check-main-open", (event, arg) => {
event.returnValue = mainWin ? true : false;
});
ipcMain.on("get-file-data", function (event) {
if (fs.existsSync(path.join(dirPath, "log.json"))) {
try {
const _data = JSON.parse(
fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}"
);
if (_data && _data.filePath) {
filePath = _data.filePath;
setTimeout(() => {
fs.writeFileSync(path.join(dirPath, "log.json"), "{}", "utf-8");
}, 1000);
}
} catch (error) {
console.error("Error reading log.json:", error);
}
}
event.returnValue = filePath;
filePath = null;
});
ipcMain.on("check-file-data", function (event) {
if (fs.existsSync(path.join(dirPath, "log.json"))) {
try {
const _data = JSON.parse(
fs.readFileSync(path.join(dirPath, "log.json"), "utf-8") || "{}"
);
if (_data && _data.filePath) {
filePath = _data.filePath;
}
} catch (error) {
console.error("Error reading log.json:", error);
}
}
event.returnValue = filePath;
filePath = null;
});
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) {
// 忽略清理失败
}
}
}
});
};
app.on("ready", () => {
createMainWin();
});
app.on("before-quit", () => {
isQuitting = true;
destroyDiscordRPC();
});
app.on("window-all-closed", () => {
app.quit();
});
app.on("open-file", (e, pathToFile) => {
filePath = pathToFile;
});
// Register protocol handler
app.setAsDefaultProtocolClient("koodo-reader");
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);
}
};