first commit

This commit is contained in:
Hydra
2024-04-18 08:46:06 +01:00
commit 91b1341271
165 changed files with 20993 additions and 0 deletions

54
src/main/constants.ts Normal file
View File

@@ -0,0 +1,54 @@
import { app } from "electron";
import os from "node:os";
import path from "node:path";
export const repackersOn1337x = [
"DODI",
"FitGirl",
"0xEMPRESS",
"KaOsKrew",
"TinyRepacks",
] as const;
export const repackers = [
...repackersOn1337x,
"Xatab",
"CPG",
"TinyRepacks",
"GOG",
] as const;
export const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
}
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
export const databasePath = path.join(
app.getPath("appData"),
app.getName(),
"hydra.db"
);
export const INSTALLATION_ID_LENGTH = 6;
export const ACTIVATION_KEY_MULTIPLIER = 7;

33
src/main/data-source.ts Normal file
View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import {
Game,
GameShopCache,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
} from "@main/entity";
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
import { databasePath } from "./constants";
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
new DataSource({
type: "sqlite",
database: databasePath,
entities: [
Game,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
GameShopCache,
MigrationScript,
],
...options,
});
export const dataSource = createDataSource({
synchronize: true,
});

View File

@@ -0,0 +1,32 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
import type { GameShop } from "@types";
@Entity("game_shop_cache")
export class GameShopCache {
@PrimaryColumn("text", { unique: true })
objectID: string;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
serializedData: string;
@Column("text", { nullable: true })
howLongToBeatSerializedData: string;
@Column("text", { nullable: true })
language: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,69 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { GameShop } from "@types";
import { Repack } from "./repack.entity";
@Entity("game")
export class Game {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
objectID: string;
@Column("text")
title: string;
@Column("text")
iconUrl: string;
@Column("text", { nullable: true })
folderName: string | null;
@Column("text", { nullable: true })
downloadPath: string | null;
@Column("text", { nullable: true })
executablePath: string | null;
@Column("int", { default: 0 })
playTimeInMilliseconds: number;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
status: string;
@Column("float", { default: 0 })
progress: number;
@Column("float", { default: 0 })
fileVerificationProgress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;
@Column("text", { nullable: true })
lastTimePlayed: Date | null;
@Column("float", { default: 0 })
fileSize: number;
@OneToOne(() => Repack, { nullable: true })
@JoinColumn()
repack: Repack;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("image_cache")
export class ImageCache {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
url: string;
@Column("text")
data: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

7
src/main/entity/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from "./game.entity";
export * from "./image-cache.entity";
export * from "./repack.entity";
export * from "./repacker-friendly-name.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./migration-script.entity";

View File

@@ -0,0 +1,22 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("migration_script")
export class MigrationScript {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
version: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("repack")
export class Repack {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
title: string;
@Column("text", { unique: true })
magnet: string;
@Column("int")
page: number;
@Column("text")
repacker: string;
@Column("text")
fileSize: string;
@Column("datetime")
uploadDate: Date | string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("repacker_friendly_name")
export class RepackerFriendlyName {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
name: string;
@Column("text")
friendlyName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("user_preferences")
export class UserPreferences {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true })
downloadsPath: string | null;
@Column("text", { default: "en" })
language: string;
@Column("boolean", { default: false })
downloadNotificationsEnabled: boolean;
@Column("boolean", { default: false })
repackUpdatesNotificationsEnabled: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,76 @@
import { formatName, repackerFormatter } from "@main/helpers";
import { getTrendingGames } from "@main/services";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames } from "../helpers/search-games";
import { registerEvent } from "../register-event";
const repacks = stateManager.getValue("repacks");
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const trendingGames = await getTrendingGames();
let i = 0;
const results: CatalogueEntry[] = [];
const getStringForLookup = (index: number) => {
if (category === "trending") return trendingGames[index];
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
if (!repacks.length) return [];
const resultSize = 12;
const requestSize = resultSize * 2;
let lookupRequest = [];
while (results.length < resultSize) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
i++;
continue;
}
lookupRequest.push(searchGames(stringForLookup));
i++;
if (lookupRequest.length < requestSize) {
continue;
}
const games = (await Promise.all(lookupRequest)).map((value) =>
value.at(0)
);
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
lookupRequest = [];
}
return results.slice(0, resultSize);
};
registerEvent(getCatalogue, {
name: "getCatalogue",
memoize: true,
});

View File

@@ -0,0 +1,72 @@
import { gameShopCacheRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
});
const result = Promise.all([
getSteamAppDetails(objectID, "english"),
getSteamAppDetails(objectID, language),
]).then(([appDetails, localizedAppDetails]) => {
if (appDetails && localizedAppDetails) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify({
...localizedAppDetails,
name: appDetails.name,
}),
},
["objectID"]
);
}
return [appDetails, localizedAppDetails];
});
const cachedGame = cachedData?.serializedData
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
: null;
if (cachedGame) {
return {
...cachedGame,
repacks: searchRepacks(cachedGame.name),
objectID,
} as ShopDetails;
}
return result.then(([appDetails, localizedAppDetails]) => {
if (!appDetails || !localizedAppDetails) return null;
return {
...localizedAppDetails,
name: appDetails.name,
repacks: searchRepacks(appDetails.name),
objectID,
} as ShopDetails;
});
}
throw new Error("Not implemented");
};
registerEvent(getGameShopDetails, {
name: "getGameShopDetails",
memoize: true,
});

View File

@@ -0,0 +1,48 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
import { registerEvent } from "../register-event";
import { gameShopCacheRepository } from "@main/repository";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
const gameShopCache = await gameShopCacheRepository.findOne({
where: { objectID, shop },
});
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
: null;
if (howLongToBeatCachedData) return howLongToBeatCachedData;
return searchHowLongToBeatPromise.then(async (response) => {
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
);
if (!game) return null;
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
gameShopCacheRepository.upsert(
{
objectID,
shop,
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
},
["objectID"]
);
return howLongToBeat;
});
};
registerEvent(getHowLongToBeat, {
name: "getHowLongToBeat",
memoize: true,
});

View File

@@ -0,0 +1,29 @@
import shuffle from "lodash/shuffle";
import { getRandomSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { formatName } from "@main/helpers";
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return getRandomSteam250List().then(async (games) => {
const shuffledList = shuffle(games);
for (const game of shuffledList) {
const repacks = searchRepacks(formatName(game));
if (repacks.length) {
const results = await searchGames(game);
if (results.length) {
return results[0].objectID;
}
}
}
});
};
registerEvent(getRandomGame, {
name: "getRandomGame",
});

View File

@@ -0,0 +1,10 @@
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
registerEvent(
(_event: Electron.IpcMainInvokeEvent, query: string) => searchGames(query),
{
name: "searchGames",
memoize: true,
}
);

View File

@@ -0,0 +1,11 @@
import checkDiskSpace from "check-disk-space";
import { registerEvent } from "../register-event";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const getDiskFreeSpace = async (_event: Electron.IpcMainInvokeEvent) =>
checkDiskSpace(await getDownloadsPath());
registerEvent(getDiskFreeSpace, {
name: "getDiskFreeSpace",
});

View File

@@ -0,0 +1,44 @@
import { Document as YMLDocument } from "yaml";
import { Game } from "@main/entity";
import path from "node:path";
export const generateYML = (game: Game) => {
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
const doc = new YMLDocument({
name: game.title,
game_slug: slugifiedGameTitle,
slug: `${slugifiedGameTitle}-installer`,
version: "Installer",
runner: "wine",
script: {
game: {
prefix: "$GAMEDIR",
arch: "win64",
working_dir: "$GAMEDIR",
},
installer: [
{
task: {
name: "create_prefix",
arch: "win64",
prefix: "$GAMEDIR",
},
},
{
task: {
executable: path.join(
game.downloadPath,
game.folderName,
"setup.exe"
),
name: "wineexec",
prefix: "$GAMEDIR",
},
},
],
},
});
return doc.toString();
};

View File

@@ -0,0 +1,15 @@
import { userPreferencesRepository } from "@main/repository";
import { defaultDownloadsPath } from "@main/constants";
export const getDownloadsPath = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: {
id: 1,
},
});
if (userPreferences && userPreferences.downloadsPath)
return userPreferences.downloadsPath;
return defaultDownloadsPath;
};

View File

@@ -0,0 +1,72 @@
import flexSearch from "flexsearch";
import orderBy from "lodash/orderBy";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { searchSteamGame } from "@main/services";
import { stateManager } from "@main/state-manager";
const { Index } = flexSearch;
const repacksIndex = new Index();
const repacks = stateManager.getValue("repacks");
for (let i = 0; i < repacks.length; i++) {
const repack = repacks[i];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
repacksIndex.add(i, formatName(formatter(repack.title)));
}
export const searchRepacks = (title: string): GameRepack[] => {
const repacks = stateManager.getValue("repacks");
return orderBy(
repacksIndex
.search(formatName(title))
.map((index) => repacks.at(index as number)!),
["uploadDate"],
"desc"
);
};
export const searchGames = async (query: string): Promise<CatalogueEntry[]> => {
const formattedName = formatName(query);
const steamResults = await searchSteamGame(formattedName);
const results = steamResults.map((result) => ({
objectID: result.objectID,
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", result.objectID),
}));
const gamesIndex = new Index({
tokenize: "full",
});
for (let i = 0; i < results.length; i++) {
const game = results[i];
gamesIndex.add(i, game.title);
}
const filteredResults = gamesIndex
.search(query)
.map((index) => results[index as number]);
return Promise.all(
filteredResults.map(async (result) => ({
...result,
repacks: searchRepacks(result.title),
}))
).then((resultsWithRepacks) =>
orderBy(
resultsWithRepacks,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
)
);
};

30
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import { app, ipcMain } from "electron";
import { defaultDownloadsPath } from "@main/constants";
import "./library/add-game-to-library";
import "./catalogue/search-games";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-catalogue";
import "./library/get-library";
import "./hardware/get-disk-free-space";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./misc/get-or-cache-image";
import "./user-preferences/update-user-preferences";
import "./user-preferences/get-user-preferences";
import "./library/get-repackers-friendly-names";
import "./library/get-game-by-object-id";
import "./library/open-game-installer";
import "./library/open-game";
import "./library/close-game";
import "./misc/show-open-dialog";
import "./library/remove-game";
import "./library/delete-game-folder";
import "./catalogue/get-random-game";
import "./catalogue/get-how-long-to-beat";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@@ -0,0 +1,27 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getImageBase64 } from "@main/helpers";
import { getSteamGameIconUrl } from "@main/services";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
title: string,
gameShop: GameShop,
) => {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
return gameRepository.insert({
title,
iconUrl,
objectID,
shop: gameShop,
});
};
registerEvent(addGameToLibrary, {
name: "addGameToLibrary",
});

View File

@@ -0,0 +1,35 @@
import path from "node:path";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { getProcesses } from "@main/helpers";
const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const processes = await getProcesses();
const game = await gameRepository.findOne({ where: { id: gameId } });
const gameProcess = processes.find((runningProcess) => {
const basename = path.win32.basename(game.executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
);
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
});
if (gameProcess) return process.kill(gameProcess.pid);
return false;
};
registerEvent(closeGame, {
name: "closeGame",
});

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import fs from "node:fs";
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: GameStatus.Cancelled,
},
});
if (!game) return;
if (game.folderName) {
const folderPath = path.join(await getDownloadsPath(), game.folderName);
if (fs.existsSync(folderPath)) {
return new Promise((resolve, reject) => {
fs.rm(
folderPath,
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
(error) => {
if (error) {
logger.error(error);
reject();
}
resolve(null);
}
);
});
}
}
};
registerEvent(deleteGameFolder, {
name: "deleteGameFolder",
});

View File

@@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
) =>
gameRepository.findOne({
where: {
objectID,
},
relations: {
repack: true,
},
});
registerEvent(getGameByObjectID, {
name: "getGameByObjectID",
});

View File

@@ -0,0 +1,30 @@
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import sortBy from "lodash/sortBy";
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
gameRepository
.find({
order: {
createdAt: "desc",
},
relations: {
repack: true,
},
})
.then((games) =>
sortBy(
games.map((game) => ({
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
)
);
registerEvent(getLibrary, {
name: "getLibrary",
});

View File

@@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getRepackersFriendlyNames = async (_event: Electron.IpcMainInvokeEvent) =>
stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
return { ...prev, [next.name]: next.friendlyName };
}, {});
registerEvent(getRepackersFriendlyNames, {
name: "getRepackersFriendlyNames",
memoize: true,
});

View File

@@ -0,0 +1,58 @@
import { gameRepository } from "@main/repository";
import { generateYML } from "../helpers/generate-lutris-yaml";
import path from "node:path";
import fs from "node:fs";
import { writeFile } from "node:fs/promises";
import { spawnSync, exec } from "node:child_process";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const openGameInstaller = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
if (!game) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName
);
if (!fs.existsSync(gamePath)) {
await gameRepository.delete({ id: gameId });
return true;
}
const setupPath = path.join(gamePath, "setup.exe");
if (!fs.existsSync(setupPath)) {
shell.openPath(gamePath);
return true;
}
if (process.platform === "win32") {
shell.openPath(setupPath);
return true;
}
if (spawnSync("which", ["lutris"]).status === 0) {
const ymlPath = path.join(gamePath, "setup.yml");
await writeFile(ymlPath, generateYML(game));
exec(`lutris --install "${ymlPath}"`);
return true;
}
if (spawnSync("which", ["wine"]).status === 0) {
exec(`wine "${setupPath}"`);
return true;
}
return false;
};
registerEvent(openGameInstaller, {
name: "openGameInstaller",
});

View File

@@ -0,0 +1,18 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
executablePath: string
) => {
await gameRepository.update({ id: gameId }, { executablePath });
shell.openPath(executablePath);
};
registerEvent(openGame, {
name: "openGame",
});

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => gameRepository.delete({ id: gameId });
registerEvent(removeGame, {
name: "removeGame",
});

View File

@@ -0,0 +1,37 @@
import { imageCacheRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { getImageBase64 } from "@main/helpers";
import { logger } from "@main/services";
const getOrCacheImage = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const cache = await imageCacheRepository.findOne({
where: {
url,
},
});
if (cache) return cache.data;
getImageBase64(url).then((data) =>
imageCacheRepository
.save({
url,
data,
})
.catch(() => {
logger.error(`Failed to cache image "${url}"`, {
method: "getOrCacheImage",
});
})
);
return url;
};
registerEvent(getOrCacheImage, {
name: "getOrCacheImage",
});

View File

@@ -0,0 +1,12 @@
import { dialog } from "electron";
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const showOpenDialog = async (
_event: Electron.IpcMainInvokeEvent,
options: Electron.OpenDialogOptions
) => dialog.showOpenDialog(WindowManager.mainWindow, options);
registerEvent(showOpenDialog, {
name: "showOpenDialog",
});

View File

@@ -0,0 +1,39 @@
import { ipcMain } from "electron";
import { stateManager } from "@main/state-manager";
interface EventArgs {
name: string;
memoize?: boolean;
}
export const registerEvent = (
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
{ name, memoize = false }: EventArgs
) => {
ipcMain.handle(name, (event: Electron.IpcMainInvokeEvent, ...args) => {
const eventResults = stateManager.getValue("eventResults");
const keys = Array.from(eventResults.keys());
const key = [name, args] as [string, any[]];
const memoizationKey = keys.find(([memoizedEvent, memoizedArgs]) => {
const sameEvent = name === memoizedEvent;
const sameArgs = memoizedArgs.every((arg, index) => arg === args[index]);
return sameEvent && sameArgs;
});
if (memoizationKey) return eventResults.get(memoizationKey);
return Promise.resolve(listener(event, ...args)).then((result) => {
if (memoize) {
eventResults.set(key, JSON.parse(JSON.stringify(result)));
stateManager.setValue("eventResults", eventResults);
}
if (!result) return result;
return JSON.parse(JSON.stringify(result));
});
});
};

View File

@@ -0,0 +1,53 @@
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { WindowManager, writePipe } from "@main/services";
import { In } from "typeorm";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
GameStatus.Paused,
GameStatus.Seeding,
]),
},
});
if (!game) return;
gameRepository
.update(
{
id: game.id,
},
{
status: GameStatus.Cancelled,
downloadPath: null,
bytesDownloaded: 0,
progress: 0,
}
)
.then((result) => {
if (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
writePipe.write({ action: "cancel" });
if (result.affected) WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(cancelGameDownload, {
name: "cancelGameDownload",
});

View File

@@ -0,0 +1,34 @@
import { WindowManager, writePipe } from "@main/services";
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await gameRepository
.update(
{
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) {
writePipe.write({ action: "pause" });
WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(pauseGameDownload, {
name: "pauseGameDownload",
});

View File

@@ -0,0 +1,56 @@
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { writePipe } from "@main/services";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
relations: { repack: true },
});
if (!game) return;
writePipe.write({ action: "pause" });
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
writePipe.write({
action: "start",
game_id: gameId,
magnet: game.repack.magnet,
save_path: downloadsPath,
});
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
}
);
}
};
registerEvent(resumeGameDownload, {
name: "resumeGameDownload",
});

View File

@@ -0,0 +1,110 @@
import { getSteamGameIconUrl, writePipe } from "@main/services";
import { gameRepository, repackRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { getImageBase64 } from "@main/helpers";
import { In } from "typeorm";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
repackId: number,
objectID: string,
title: string,
gameShop: GameShop
) => {
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
if (game?.status === GameStatus.Downloading) {
return;
}
writePipe.write({ action: "pause" });
const downloadsPath = game?.downloadPath ?? (await getDownloadsPath());
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
}
);
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
game.status = GameStatus.DownloadingMetadata;
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
return game;
} else {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
const createdGame = await gameRepository.save({
title,
iconUrl,
objectID,
shop: gameShop,
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
});
writePipe.write({
action: "start",
game_id: createdGame.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
const { repack: _, ...rest } = createdGame;
return rest;
}
};
registerEvent(startGameDownload, {
name: "startGameDownload",
});

View File

@@ -0,0 +1,11 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getUserPreferences = async (_event: Electron.IpcMainInvokeEvent) =>
userPreferencesRepository.findOne({
where: { id: 1 },
});
registerEvent(getUserPreferences, {
name: "getUserPreferences",
});

View File

@@ -0,0 +1,21 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences>
) => {
await userPreferencesRepository.upsert(
{
id: 1,
...preferences,
},
["id"]
);
};
registerEvent(updateUserPreferences, {
name: "updateUserPreferences",
});

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
dodiFormatter,
empressFormatter,
fitGirlFormatter,
kaosKrewFormatter,
} from "./formatters";
describe("testing formatters", () => {
describe("testing fitgirl formatter", () => {
const fitGirlGames = [
"REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
"Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
"HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
"SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
"God of Rock (v3110, MULTi11) [FitGirl Repack]",
];
test("should format games correctly", () => {
assert.equal(fitGirlGames.map(fitGirlFormatter), [
"REVEIL",
"Dune: Spice Wars - The Ixian Edition",
"HUMANKIND: Premium Edition",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
"SUPER BOMBERMAN R 2",
"God of Rock",
]);
});
});
describe("testing kaoskrew formatter", () => {
const kaosKrewGames = [
"Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
"Remoteness.REPACK-KaOs",
"Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
"The.Wreck.MULTi5.REPACK-KaOs",
"Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
"The.World.Of.Others.v1.05.REPACK-KaOs",
];
test("should format games correctly", () => {
assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
"Song Of Horror Complete Edition",
"Remoteness",
"Persona 5 Royal NSW For PC",
"The Wreck",
"Nemezis Mysterious Journey III Deluxe Edition",
"The World Of Others",
]);
});
});
describe("testing empress formatter", () => {
const empressGames = [
"Resident.Evil.4-EMPRESS",
"Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
"Life.is.Strange.2.Complete.Edition-EMPRESS",
"Forza.Horizon.4.PROPER-EMPRESS",
"Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
"Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
];
test("should format games correctly", () => {
assert.equal(empressGames.map(empressFormatter), [
"Resident Evil 4",
"Marvels Guardians of the Galaxy",
"Life is Strange 2 Complete Edition",
"Forza Horizon 4 PROPER",
"Just Cause 4 Complete Edition",
"Immortals Fenyx Rising",
]);
});
});
describe("testing kodi formatter", () => {
const dodiGames = [
"Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
"Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
"Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
"DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
"Outliver: Tribulation [DODI Repack]",
];
test("should format games correctly", () => {
assert.equal(dodiGames.map(dodiFormatter), [
"Tomb Raider I-III Remastered Starring Lara Croft",
"Trail Out: Complete Edition",
"Call to Arms - Gates of Hell: Ostfront",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
"DREDGE: Digital Deluxe Edition",
"Outliver: Tribulation",
]);
});
});
});

View File

@@ -0,0 +1,54 @@
/* String formatting */
export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, "");
export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, "");
export const removeSpecialEditionFromName = (name: string) =>
name.replace(
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
""
);
export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " ");
export const removeTrash = (title: string) =>
title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
/* Formatters per repacker */
export const fitGirlFormatter = (title: string) =>
title.replace(/\(.*\)/g, "").trim();
export const kaosKrewFormatter = (title: string) =>
title
.replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
.replace(
/(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
""
)
.replace(/\./g, " ")
.trim();
export const empressFormatter = (title: string) =>
title
.replace(/-EMPRESS/, "")
.replace(/\./g, " ")
.trim();
export const dodiFormatter = (title: string) =>
title.replace(/\(.*?\)/g, "").trim();
export const xatabFormatter = (title: string) =>
title
.replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
.replace(/[\u0400-\u04FF]/g, "")
.replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
export const tinyRepacksFormatter = (title: string) => title;
export const gogFormatter = (title: string) =>
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");

83
src/main/helpers/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import {
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
empressFormatter,
kaosKrewFormatter,
fitGirlFormatter,
removeDuplicateSpaces,
dodiFormatter,
removeTrash,
xatabFormatter,
tinyRepacksFormatter,
gogFormatter,
} from "./formatters";
import { months, repackers } from "../constants";
export const pipe =
<T>(...fns: ((arg: T) => any)[]) =>
(arg: T) =>
fns.reduce((prev, fn) => fn(prev), arg);
export const formatName = pipe<string>(
removeTrash,
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
removeDuplicateSpaces,
(str) => str.trim()
);
export const repackerFormatter: Record<
(typeof repackers)[number],
(title: string) => string
> = {
DODI: dodiFormatter,
"0xEMPRESS": empressFormatter,
KaOsKrew: kaosKrewFormatter,
FitGirl: fitGirlFormatter,
Xatab: xatabFormatter,
CPG: (title: string) => title,
TinyRepacks: tinyRepacksFormatter,
GOG: gogFormatter,
};
export const formatUploadDate = (str: string) => {
const date = new Date();
const [month, day, year] = str.split(" ");
date.setMonth(months.indexOf(month.replace(".", "")));
date.setDate(Number(day.substring(0, 2)));
date.setFullYear(Number("20" + year.replace("'", "")));
date.setHours(0, 0, 0, 0);
return date;
};
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
objectID: string,
clientIcon?: string
) => {
if (category === "library")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
if (category === "hero")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
if (category === "logo")
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
};
export const getImageBase64 = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => {
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
})
);
export * from "./formatters";
export * from "./ps";

12
src/main/helpers/ps.ts Normal file
View File

@@ -0,0 +1,12 @@
import psList from "ps-list";
import { tasklist } from "tasklist";
export const getProcesses = async () => {
if (process.platform === "win32") {
return tasklist().then((tasks) =>
tasks.map((task) => ({ ...task, name: task.imageName }))
);
}
return psList();
};

118
src/main/index.ts Normal file
View File

@@ -0,0 +1,118 @@
import { stateManager } from "./state-manager";
import { GameStatus, repackers } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
readPipe,
startProcessWatcher,
writePipe,
} from "./services";
import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/torrent-client";
import { Repack } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import { In } from "typeorm";
startProcessWatcher();
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
relations: { repack: true },
});
if (game) {
writePipe.write({
action: "start",
game_id: game.id,
magnet: game.repack.magnet,
save_path: game.downloadPath,
});
}
readPipe.socket.on("data", (data) => {
TorrentClient.onSocketData(data);
});
});
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
);
}
};
const checkForNewRepacks = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewRepacksFromCPG(
existingRepacks.filter((repack) => repack.repacker === "CPG")
),
track1337xUsers(existingRepacks),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
if (total > 0 && userPreferences.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: total,
}),
}).show();
}
});
});
};
const loadState = async () => {
const [friendlyNames, repacks] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
]);
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
import("./events");
};
loadState().then(() => checkForNewRepacks());

27
src/main/repository.ts Normal file
View File

@@ -0,0 +1,27 @@
import { dataSource } from "./data-source";
import {
Game,
GameShopCache,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
export const imageCacheRepository = dataSource.getRepository(ImageCache);
export const repackRepository = dataSource.getRepository(Repack);
export const repackerFriendlyNameRepository =
dataSource.getRepository(RepackerFriendlyName);
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const migrationScriptRepository =
dataSource.getRepository(MigrationScript);

38
src/main/services/fifo.ts Normal file
View File

@@ -0,0 +1,38 @@
import path from "node:path";
import net from "node:net";
import crypto from "node:crypto";
import os from "node:os";
export class FIFO {
public socket: null | net.Socket = null;
public socketPath = this.generateSocketFilename();
private generateSocketFilename() {
const hash = crypto.randomBytes(16).toString("hex");
if (process.platform === "win32") {
return "\\\\.\\pipe\\" + hash;
}
return path.join(os.tmpdir(), hash);
}
public write(data: any) {
if (!this.socket) return;
this.socket.write(Buffer.from(JSON.stringify(data)));
}
public createPipe() {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
this.socket = socket;
resolve(null);
});
server.listen(this.socketPath);
});
}
}
export const writePipe = new FIFO();
export const readPipe = new FIFO();

View File

@@ -0,0 +1,60 @@
import { formatName } from "@main/helpers";
import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "./repack-tracker/helpers";
import { HowLongToBeatCategory } from "@types";
export interface HowLongToBeatResult {
game_id: number;
profile_steam: number;
}
export interface HowLongToBeatSearchResponse {
data: HowLongToBeatResult[];
}
export const searchHowLongToBeat = async (gameName: string) => {
const response = await axios.post(
"https://howlongtobeat.com/api/search",
{
searchType: "games",
searchTerms: formatName(gameName).split(" "),
searchPage: 1,
size: 100,
},
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
Referer: "https://howlongtobeat.com/",
},
}
);
return response.data as HowLongToBeatSearchResponse;
};
export const getHowLongToBeatGame = async (
id: string
): Promise<HowLongToBeatCategory[]> => {
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
const { window } = new JSDOM(response);
const { document } = window;
const $ul = document.querySelector(".shadow_shadow ul");
const $lis = Array.from($ul.children);
return $lis.map(($li) => {
const title = $li.querySelector("h4").textContent;
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
const accuracy = accuracyClassName.split("time_").at(1);
return {
title,
duration: $li.querySelector("h5").textContent,
accuracy,
};
});
};

View File

@@ -0,0 +1,11 @@
export * from "./logger";
export * from "./repack-tracker";
export * from "./steam";
export * from "./steam-250";
export * from "./steam-grid";
export * from "./update-resolver";
export * from "./window-manager";
export * from "./fifo";
export * from "./torrent-client";
export * from "./how-long-to-beat";
export * from "./process-watcher";

View File

@@ -0,0 +1,11 @@
import winston from "winston";
export const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "info.log", level: "info" }),
new winston.transports.File({ filename: "combined.log" }),
],
});

View File

@@ -0,0 +1,77 @@
import path from "node:path";
import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const startProcessWatcher = async () => {
const sleepTime = 100;
const gamesPlaytime = new Map<number, number>();
// eslint-disable-next-line no-constant-condition
while (true) {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
status: GameStatus.Seeding,
},
});
const processes = await getProcesses();
for (const game of games) {
const gameProcess = processes.find((runningProcess) => {
const basename = path.win32.basename(game.executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
);
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(
runningProcess.name
);
});
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
const zero = gamesPlaytime.get(game.id);
const delta = performance.now() - zero;
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
await gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
});
gamesPlaytime.set(game.id, performance.now());
await sleep(sleepTime);
continue;
}
gamesPlaytime.set(game.id, performance.now());
gameRepository.update(game.id, {
lastTimePlayed: new Date().toUTCString(),
});
await sleep(sleepTime);
continue;
}
if (gamesPlaytime.has(game.id)) {
gamesPlaytime.delete(game.id);
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
}
await sleep(sleepTime);
}
}
};

View File

@@ -0,0 +1,135 @@
import { JSDOM } from "jsdom";
import { formatUploadDate } from "@main/helpers";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
/* TODO: $a will often be null */
const getTorrentDetails = async (path: string) => {
const response = await request1337x(path);
const { window } = new JSDOM(response);
const { document } = window;
const $a = window.document.querySelector(
".torrentdown1"
) as HTMLAnchorElement;
const $ul = Array.from(
document.querySelectorAll(".torrent-detail-page .list")
);
const [$firstColumn, $secondColumn] = $ul;
if (!$firstColumn || !$secondColumn) {
return { magnet: $a?.href };
}
const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
return {
magnet: $a?.href,
fileSize: $totalSize.querySelector("span").textContent ?? undefined,
uploadDate: formatUploadDate(
$dateUploaded.querySelector("span").textContent!
),
};
};
export const getTorrentListLastPage = async (user: string) => {
const response = await request1337x(`/user/${user}/1`);
const { window } = new JSDOM(response);
const $ul = window.document.querySelector(".pagination > ul");
if ($ul) {
const $li = Array.from($ul.querySelectorAll("li")).at(-1);
const text = $li?.textContent;
if (text === ">>") {
const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
return Number($previousLi?.textContent);
}
return Number(text);
}
return -1;
};
export const extractTorrentsFromDocument = async (
page: number,
user: string,
document: Document,
existingRepacks: Repack[] = []
): Promise<GameRepackInput[]> => {
const $trs = Array.from(document.querySelectorAll("tbody tr"));
return Promise.all(
$trs.map(async ($tr) => {
const $td = $tr.querySelector("td");
const [, $name] = Array.from($td!.querySelectorAll("a"));
const url = $name.href;
const title = $name.textContent ?? "";
if (existingRepacks.some((repack) => repack.title === title)) {
return {
title,
magnet: "",
fileSize: null,
uploadDate: null,
repacker: user,
page,
};
}
const details = await getTorrentDetails(url);
return {
title,
magnet: details.magnet,
fileSize: details.fileSize ?? null,
uploadDate: details.uploadDate ?? null,
repacker: user,
page,
};
})
);
};
export const getNewRepacksFromUser = async (
user: string,
existingRepacks: Repack[],
page = 1
): Promise<Repack[]> => {
const response = await request1337x(`/user/${user}/${page}`);
const { window } = new JSDOM(response);
const repacks = await extractTorrentsFromDocument(
page,
user,
window.document,
existingRepacks
);
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromUser(user, existingRepacks, page + 1);
};

View File

@@ -0,0 +1,65 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
import { logger } from "../logger";
export const getNewRepacksFromCPG = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://cpgrepacks.site/page/${page}`);
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
try {
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
const $title = $post.querySelector(".entry-title");
const uploadDate = $post.querySelector("time").getAttribute("datetime");
const $downloadInfo = Array.from(
$post.querySelectorAll(".wp-block-heading")
).find(($heading) => $heading.textContent.startsWith("Download"));
/* Side note: CPG often misspells "Magnet" as "Magent" */
const $magnet = Array.from($post.querySelectorAll("a")).find(
($a) =>
$a.textContent.startsWith("Magnet") ||
$a.textContent.startsWith("Magent")
);
const fileSize = $downloadInfo.textContent
.split("Download link => ")
.at(1);
repacks.push({
title: $title.textContent,
fileSize: fileSize ?? "N/A",
magnet: $magnet.href,
repacker: "CPG",
page,
uploadDate: new Date(uploadDate),
});
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromCPG" });
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromCPG(existingRepacks, page + 1);
};

View File

@@ -0,0 +1,78 @@
import { JSDOM, VirtualConsole } from "jsdom";
import { GameRepackInput, requestWebPage, savePage } from "./helpers";
import { Repack } from "@main/entity";
import { logger } from "../logger";
const virtualConsole = new VirtualConsole();
const getGOGGame = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data, { virtualConsole });
const $modifiedTime = window.document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
const $em = window.document.querySelector(
"p:not(.lightweight-accordion *) em"
);
const fileSize = $em.textContent.split("Size: ").at(1);
const $downloadButton = window.document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
const { searchParams } = new URL($downloadButton.href);
const magnet = Buffer.from(searchParams.get("url"), "base64").toString(
"utf-8"
);
return {
fileSize: fileSize ?? "N/A",
uploadDate: new Date($modifiedTime.content),
repacker: "GOG",
magnet,
page: 1,
};
};
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
try {
const data = await requestWebPage(
"https://freegogpcgames.com/a-z-games-list/"
);
const { window } = new JSDOM(data, { virtualConsole });
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
for (const $ul of $uls) {
const repacks: GameRepackInput[] = [];
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $li of $lis) {
const $a = $li.querySelector("a");
const href = $a.href;
const title = $a.textContent.trim();
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title
);
if (!gameExists) {
try {
const game = await getGOGGame(href);
repacks.push({ ...game, title });
} catch (err) {
logger.error(err.message, { method: "getGOGGame", url: href });
}
}
}
if (repacks.length) await savePage(repacks);
}
} catch (err) {
logger.error(err.message, { method: "getNewGOGGames" });
}
};

View File

@@ -0,0 +1,18 @@
import { repackRepository } from "@main/repository";
import type { GameRepack } from "@types";
export type GameRepackInput = Omit<
GameRepack,
"id" | "repackerFriendlyName" | "createdAt" | "updatedAt"
>;
export const savePage = async (repacks: GameRepackInput[]) =>
Promise.all(
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
);
export const requestWebPage = async (url: string) =>
fetch(url, {
method: "GET",
}).then((response) => response.text());

View File

@@ -0,0 +1,4 @@
export * from "./1337x";
export * from "./xatab";
export * from "./cpg-repacks";
export * from "./gog";

View File

@@ -0,0 +1,95 @@
import { JSDOM } from "jsdom";
import parseTorrent, { toMagnetURI } from "parse-torrent";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
const getTorrentBuffer = (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
const formatXatabDate = (str: string) => {
const date = new Date();
const [day, month, year] = str.split(".");
date.setDate(Number(day));
date.setMonth(Number(month) - 1);
date.setFullYear(Number(year));
date.setHours(0, 0, 0, 0);
return date;
};
const formatXatabDownloadSize = (str: string) =>
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
const getXatabRepack = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const $uploadDate = window.document.querySelector(".entry__date");
const $size = window.document.querySelector(".entry__info-size");
const $downloadButton = window.document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) throw new Error("Download button not found");
const torrentBuffer = await getTorrentBuffer($downloadButton.href);
return {
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
magnet: toMagnetURI({
infoHash: parseTorrent(torrentBuffer).infoHash,
}),
uploadDate: formatXatabDate($uploadDate.textContent),
};
};
export const getNewRepacksFromXatab = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://byxatab.com/page/${page}`);
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
for (const $a of Array.from(
window.document.querySelectorAll(".entry__title a")
)) {
try {
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
repacks.push({
title: $a.textContent,
repacker: "Xatab",
...repack,
page,
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromXatab" });
}
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromXatab(existingRepacks, page + 1);
};

View File

@@ -0,0 +1,46 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import shuffle from "lodash/shuffle";
import { logger } from "./logger";
const requestSteam250 = async (path: string) => {
return axios
.get(`https://steam250.com${path}`)
.then((response) => response.data);
};
export const getTrendingGames = async () => {
const response = await requestSteam250("/365day").catch((err) => {
logger.error(err.response, { method: "getTrendingGames" });
throw new Error(err);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
};
const steam250Paths = [
"/hidden_gems",
`/${new Date().getFullYear()}`,
"/top250",
"/most_played",
];
export const getRandomSteam250List = async () => {
const [path] = shuffle(steam250Paths);
const response = await requestSteam250(path).catch((err) => {
logger.error(err.response, { method: "getRandomSteam250List" });
throw new Error(err);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
};

View File

@@ -0,0 +1,71 @@
import { getSteamAppAsset } from "@main/helpers";
export interface SteamGridResponse {
success: boolean;
data: {
id: number;
};
}
export interface SteamGridGameResponse {
data: {
platforms: {
steam: {
metadata: {
clienticon: string;
};
};
};
};
}
export const getSteamGridData = async (
objectID: string,
path: string,
shop: string,
params: Record<string, string> = {}
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);
const response = await fetch(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.STEAMGRIDDB_API_KEY}`,
},
}
);
return response.json();
};
export const getSteamGridGameById = async (
id: number
): Promise<SteamGridGameResponse> => {
const response = await fetch(
`https://www.steamgriddb.com/api/public/game/${id}`,
{
method: "GET",
headers: {
Referer: "https://www.steamgriddb.com/",
},
}
);
return response.json();
};
export const getSteamGameIconUrl = async (objectID: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectID, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return getSteamAppAsset(
"icon",
objectID,
steamGridGame.data.platforms.steam.metadata.clienticon
);
};

View File

@@ -0,0 +1,78 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import type { SteamAppDetails } from "@types";
import { logger } from "./logger";
export interface SteamAppDetailsResponse {
[key: string]: {
success: boolean;
data: SteamAppDetails;
};
}
export const getSteamAppDetails = async (
objectID: string,
language: string
) => {
const searchParams = new URLSearchParams({
appids: objectID,
l: language,
});
return axios
.get(
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
)
.then((response) => {
if (response.data[objectID].success) return response.data[objectID].data;
return null;
})
.catch((err) => {
logger.error(err, { method: "getSteamAppDetails" });
throw new Error(err);
});
};
export const searchSteamGame = async (term: string) => {
const searchParams = new URLSearchParams({
start: "0",
count: "12",
sort_by: "_ASC",
/* Games only */
category1: "998",
term: term,
});
const response = await axios.get(
`https://store.steampowered.com/search/results/?${searchParams.toString()}`
);
const { window } = new JSDOM(response.data);
const { document } = window;
const $anchors = Array.from(
document.querySelectorAll("#search_resultsRows a")
);
return $anchors.reduce((prev, $a) => {
const $title = $a.querySelector(".title");
const objectIDs = $a.getAttribute("data-ds-appid");
if (!objectIDs) return prev;
const [objectID] = objectIDs.split(",");
if (!objectID || prev.some((game) => game.objectID === objectID))
return prev;
return [
...prev,
{
name: $title.textContent,
objectID,
},
];
}, []);
};

View File

@@ -0,0 +1,161 @@
import path from "node:path";
import cp from "node:child_process";
import * as Sentry from "@sentry/electron/main";
import { Notification, app } from "electron";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { t } from "i18next";
import { WindowManager } from "./window-manager";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
enum TorrentState {
CheckingFiles = 1,
DownloadingMetadata = 2,
Downloading = 3,
Finished = 4,
Seeding = 5,
}
export interface TorrentUpdate {
gameId: number;
progress: number;
downloadSpeed: number;
timeRemaining: number;
numPeers: number;
numSeeds: number;
status: TorrentState;
folderName: string;
fileSize: number;
bytesDownloaded: number;
}
export const BITTORRENT_PORT = "5881";
export class TorrentClient {
public static startTorrentClient(
writePipePath: string,
readPipePath: string
) {
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
if (app.isPackaged) {
const binaryName = binaryNameByPlatform[process.platform];
const binaryPath = path.join(
process.resourcesPath,
"dist",
"hydra-download-manager",
binaryName
);
cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true,
});
return;
}
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
}
private static getTorrentStateName(state: TorrentState) {
if (state === TorrentState.CheckingFiles) return "checking_files";
if (state === TorrentState.Downloading) return "downloading";
if (state === TorrentState.DownloadingMetadata)
return "downloading_metadata";
if (state === TorrentState.Finished) return "finished";
if (state === TorrentState.Seeding) return "seeding";
return "";
}
private static getGameProgress(game: Game) {
if (game.status === "checking_files") return game.fileVerificationProgress;
return game.progress;
}
public static async onSocketData(data: Buffer) {
const message = Buffer.from(data).toString("utf-8");
try {
const payload = JSON.parse(message) as TorrentUpdate;
const updatePayload: QueryDeepPartialEntity<Game> = {
bytesDownloaded: payload.bytesDownloaded,
status: this.getTorrentStateName(payload.status),
};
if (payload.status === TorrentState.CheckingFiles) {
updatePayload.fileVerificationProgress = payload.progress;
} else {
if (payload.folderName) {
updatePayload.folderName = payload.folderName;
updatePayload.fileSize = payload.fileSize;
}
}
if (
[TorrentState.Downloading, TorrentState.Seeding].includes(
payload.status
)
) {
updatePayload.progress = payload.progress;
}
await gameRepository.update({ id: payload.gameId }, updatePayload);
const game = await gameRepository.findOne({
where: { id: payload.gameId },
relations: { repack: true },
});
if (game.progress === 1) {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: game.title,
}),
}).show();
}
}
if (WindowManager.mainWindow) {
const progress = this.getGameProgress(game);
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...payload, game }))
);
}
} catch (err) {
Sentry.captureException(err);
Sentry.captureMessage(message, "error");
}
}
}

View File

@@ -0,0 +1,144 @@
import path from "node:path";
import { app } from "electron";
import chunk from "lodash/chunk";
import { createDataSource, dataSource } from "@main/data-source";
import { Repack, RepackerFriendlyName } from "@main/entity";
import {
migrationScriptRepository,
repackRepository,
repackerFriendlyNameRepository,
} from "@main/repository";
import { MigrationScript } from "@main/entity/migration-script.entity";
import { Like } from "typeorm";
const migrationScripts = {
/*
0.0.6 -> 0.0.7
Xatab repacks were previously created with an incorrect upload date.
This migration script will update the upload date of all Xatab repacks.
*/
"0.0.7": async (updateRepacks: Repack[]) => {
const VERSION = "0.0.7";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
const xatabRepacks = updateRepacks.filter(
(repack) => repack.repacker === "Xatab"
);
await dataSource.transaction(async (transactionalEntityManager) => {
await Promise.all(
xatabRepacks.map((repack) =>
transactionalEntityManager.getRepository(Repack).update(
{
title: repack.title,
repacker: repack.repacker,
},
{
uploadDate: repack.uploadDate,
}
)
)
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
/*
1.0.1 -> 1.1.0
A few torrents scraped from 1337x were previously created with an incorrect upload date.
*/
"1.1.0": async () => {
const VERSION = "1.1.0";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
await dataSource.transaction(async (transactionalEntityManager) => {
const repacks = await transactionalEntityManager
.getRepository(Repack)
.find({
where: {
uploadDate: Like("1%"),
},
});
await Promise.all(
repacks.map(async (repack) => {
return transactionalEntityManager
.getRepository(Repack)
.update(
{ id: repack.id },
{ uploadDate: new Date(repack.uploadDate) }
);
})
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
};
export const runMigrationScripts = async (updateRepacks: Repack[]) => {
return Promise.all(
Object.values(migrationScripts).map((migrationScript) => {
return migrationScript(updateRepacks);
})
);
};
export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "resources", "hydra.db"),
});
return updateDataSource.initialize().then(async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepackerFriendlyNameRepository =
updateDataSource.getRepository(RepackerFriendlyName);
const [updateRepacks, updateRepackerFriendlyNames] = await Promise.all([
updateRepackRepository.find(),
updateRepackerFriendlyNameRepository.find(),
]);
await runMigrationScripts(updateRepacks);
await repackerFriendlyNameRepository
.createQueryBuilder()
.insert()
.values(updateRepackerFriendlyNames)
.orIgnore()
.execute();
const updateRepacksChunks = chunk(updateRepacks, 800);
for (const chunk of updateRepacksChunks) {
await repackRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View File

@@ -0,0 +1,107 @@
import { BrowserWindow, Menu, Tray, app } from "electron";
import { t } from "i18next";
import path from "node:path";
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static createMainWindow() {
// Create the browser window.
this.mainWindow = new BrowserWindow({
width: 1200,
height: 720,
minWidth: 1024,
minHeight: 540,
titleBarStyle: "hidden",
icon: path.join(__dirname, "..", "..", "images", "icon.png"),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
},
});
this.mainWindow.removeMenu();
this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
this.mainWindow.webContents.on("did-finish-load", () => {
if (!app.isPackaged) {
// Open the DevTools.
this.mainWindow.webContents.openDevTools();
}
});
this.mainWindow.on("close", () => {
WindowManager.mainWindow.setProgressBar(-1);
});
}
public static redirect(path: string) {
if (!this.mainWindow) this.createMainWindow();
this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
}
public static createSystemTray(language: string) {
const tray = new Tray(
app.isPackaged
? path.join(process.resourcesPath, "icon_tray.png")
: path.join(__dirname, "..", "..", "resources", "icon_tray.png")
);
const contextMenu = Menu.buildFromTemplate([
{
label: t("open", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
},
},
{
label: t("quit", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => app.quit(),
},
]);
tray.setToolTip("Hydra");
tray.setContextMenu(contextMenu);
if (process.platform === "win32") {
tray.addListener("click", () => {
if (this.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
return;
}
this.createMainWindow();
});
}
}
}

31
src/main/state-manager.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { Repack, RepackerFriendlyName } from "@main/entity";
interface State {
repacks: Repack[];
repackersFriendlyNames: RepackerFriendlyName[];
eventResults: Map<[string, any[]], any>;
}
const initialState: State = {
repacks: [],
repackersFriendlyNames: [],
eventResults: new Map(),
};
export class StateManager {
private state = initialState;
public setValue<T extends keyof State>(key: T, value: State[T]) {
this.state = { ...this.state, [key]: value };
}
public getValue<T extends keyof State>(key: T) {
return this.state[key];
}
public clearValue<T extends keyof State>(key: T) {
this.state = { ...this.state, [key]: initialState[key] };
}
}
export const stateManager = new StateManager();