Files
hydra/src/main/index.ts
2025-09-19 17:56:44 +03:00

241 lines
6.2 KiB
TypeScript

import { app, BrowserWindow, net, protocol } from "electron";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import {
logger,
clearGamesPlaytime,
WindowManager,
Lock,
} from "@main/services";
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, levelKeys } from "./level";
import { loadState } from "./main";
const { autoUpdater } = updater;
autoUpdater.setFeedURL({
provider: "github",
owner: "hydralauncher",
repo: "hydra",
});
autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
if (process.platform !== "linux") {
app.commandLine.appendSwitch("--no-sandbox");
}
i18n.init({
resources,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
const PROTOCOL = "hydralauncher";
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
electronApp.setAppUserModelId("gg.hydralauncher.hydra");
protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length);
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
protocol.handle("gradient", (request) => {
const gradientCss = decodeURIComponent(
request.url.slice("gradient:".length)
);
// Parse gradient CSS safely without regex to prevent ReDoS
let direction = "45deg";
let color1 = "#4a90e2";
let color2 = "#7b68ee";
// Simple string parsing approach - more secure than regex
if (
gradientCss.startsWith("linear-gradient(") &&
gradientCss.endsWith(")")
) {
const content = gradientCss.slice(16, -1); // Remove "linear-gradient(" and ")"
const parts = content.split(",").map((part) => part.trim());
if (parts.length >= 3) {
direction = parts[0];
color1 = parts[1];
color2 = parts[2];
}
}
let x1 = "0%",
y1 = "0%",
x2 = "100%",
y2 = "100%";
if (direction === "to right") {
y2 = "0%";
} else if (direction === "to bottom") {
x2 = "0%";
} else if (direction === "45deg") {
y1 = "100%";
y2 = "0%";
} else if (direction === "225deg") {
x1 = "100%";
x2 = "0%";
} else if (direction === "315deg") {
x1 = "100%";
y1 = "100%";
x2 = "0%";
y2 = "0%";
}
// Note: "135deg" case removed as it uses all default values
const svgContent = `
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#grad)" />
</svg>
`;
return new Response(svgContent, {
headers: { "Content-Type": "image/svg+xml" },
});
});
await loadState();
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.catch(() => "en");
if (language) i18n.changeLanguage(language);
if (!process.argv.includes("--hidden")) {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
});
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
const handleDeepLinkPath = (uri?: string) => {
if (!uri) return;
try {
const url = new URL(uri);
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
return;
}
if (url.host === "profile") {
const userId = url.searchParams.get("userId");
if (userId) {
WindowManager.redirect(`profile/${userId}`);
}
return;
}
if (url.host === "install-theme") {
const themeName = url.searchParams.get("theme");
const authorId = url.searchParams.get("authorId");
const authorName = url.searchParams.get("authorName");
if (themeName && authorId && authorName) {
WindowManager.redirect(
`settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}`
);
}
}
} catch (error) {
logger.error("Error handling deep link", uri, error);
}
};
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
handleDeepLinkPath(commandLine.pop());
});
app.on("open-url", (_event, url) => {
handleDeepLinkPath(url);
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
WindowManager.mainWindow = null;
});
let canAppBeClosed = false;
app.on("before-quit", async (e) => {
await Lock.releaseLock();
if (!canAppBeClosed) {
e.preventDefault();
/* Disconnects libtorrent */
PythonRPC.kill();
await clearGamesPlaytime();
canAppBeClosed = true;
app.quit();
}
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
WindowManager.createMainWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.