Merge branch 'main' into romanian

This commit is contained in:
Zamitto 2024-06-10 13:04:01 -03:00 committed by GitHub
commit d29f266ca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
129 changed files with 3057 additions and 2300 deletions

View File

@ -44,6 +44,7 @@ jobs:
name: Build-${{ matrix.os }}
path: |
dist/win-unpacked/**
dist/*-portable.exe
dist/*.zip
dist/*.dmg
dist/*.deb

Binary file not shown.

View File

@ -5,7 +5,6 @@ directories:
extraResources:
- aria2
- seeds
- hydra.db
- fastlist.exe
files:
- "!**/.vscode/*"
@ -19,12 +18,19 @@ asarUnpack:
win:
executableName: Hydra
requestedExecutionLevel: requireAdministrator
target:
- nsis
- portable
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
oneClick: false
allowToChangeInstallationDirectory: true
portable:
artifactName: ${name}-${version}-portable.${ext}
requestExecutionLevel: admin
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:

View File

@ -8,6 +8,7 @@ import {
import react from "@vitejs/plugin-react";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
export default defineConfig(({ mode }) => {
loadEnv(mode);

BIN
hydra.db

Binary file not shown.

View File

@ -40,7 +40,6 @@
"@reduxjs/toolkit": "^2.2.3",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"iso-639-1": "3.1.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",
"axios": "^1.6.8",
@ -49,6 +48,7 @@
"classnames": "^2.5.1",
"color": "^4.2.3",
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
@ -56,10 +56,13 @@
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"icojs": "^0.19.3",
"iso-639-1": "3.1.2",
"jsdom": "^24.0.0",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
@ -67,7 +70,8 @@
"react-router-dom": "^6.22.3",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1"
"yaml": "^2.4.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
@ -83,9 +87,10 @@
"@types/parse-torrent": "^5.8.7",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^28.2.0",
"electron": "^30.0.9",
"electron-builder": "^24.9.1",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",

View File

@ -1,5 +0,0 @@
libtorrent
cx_Freeze
cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32'

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Featured",
"recently_added": "Recently added",
"trending": "Trending",
"surprise_me": "Surprise me",
"no_results": "No results found"
@ -15,12 +14,9 @@
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library",
"follow_us": "Follow us",
"home": "Home",
"discord": "Join our Discord",
"telegram": "Join our Telegram",
"x": "Follow on X",
"github": "Contribute on GitHub"
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected"
},
"header": {
"search": "Search games",
@ -29,7 +25,8 @@
"downloads": "Downloads",
"search_results": "Search results",
"settings": "Settings",
"version_available": "Version {{version}} available. Click here to restart and install."
"version_available_install": "Version {{version}} available. Click here to restart and install.",
"version_available_download": "Version {{version}} available. Click here to download."
},
"bottom_panel": {
"no_downloads_in_progress": "No downloads in progress",
@ -104,7 +101,25 @@
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"downloader": "Downloader"
"downloader": "Downloader",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
"open_download_location": "See downloaded files",
"create_shortcut": "Create desktop shortcut",
"remove_files": "Remove files",
"remove_from_library_title": "Are you sure?",
"remove_from_library_description": "This will remove {{game}} from your library",
"options": "Options",
"executable_section_title": "Executable",
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Check out updates or other versions of this game",
"danger_zone_section_title": "Danger zone",
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option"
},
"activation": {
"title": "Activate Hydra",
@ -134,7 +149,13 @@
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
"install": "Install",
"download_in_progress": "In progress",
"queued_downloads": "Queued downloads",
"downloads_completed": "Completed",
"queued": "Queued",
"no_downloads_title": "Such empty",
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
},
"settings": {
"downloads_path": "Downloads path",
@ -149,6 +170,7 @@
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"download_sources": "Download sources",
"language": "Language",
"real_debrid_api_token": "API Token",
"enable_real_debrid": "Enable Real-Debrid",
@ -158,7 +180,25 @@
"real_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to Real-Debrid",
"real_debrid_linked_message": "Account \"{{username}}\" linked",
"save_changes": "Save changes",
"changes_saved": "Changes successfully saved"
"changes_saved": "Changes successfully saved",
"download_sources_description": "Hydra will fetch the download links from these sources. The source URL must be a direct link to a .json file containing the download links.",
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"download_count_zero": "No downloads in list",
"download_count_one": "{{countFormatted}} download in list",
"download_count_other": "{{countFormatted}} downloads in list",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"added_download_source": "Added download source",
"download_sources_synced": "All download sources are synced"
},
"notifications": {
"download_complete": "Download complete",
@ -181,5 +221,8 @@
},
"modal": {
"close": "Close button"
},
"forms": {
"toggle_password_visibility": "Toggle password visibility"
}
}

View File

@ -29,7 +29,8 @@
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
"version_available": "Version {{version}} disponible. Haz clic aquí para reiniciar e instalar."
"version_available_install": "Version {{version}} disponible. Haz clic aquí para reiniciar e instalar.",
"version_available_download": "Version {{version}} disponible. Haz clic aquí para descargar."
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Destaque",
"recently_added": "Recém adicionados",
"trending": "Populares",
"surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado"
@ -17,10 +16,8 @@
"filter": "Filtrar biblioteca",
"home": "Início",
"follow_us": "Acompanhe-nos",
"discord": "Entre no nosso Discord",
"telegram": "Entre no nosso Telegram",
"x": "Siga-nos no X",
"github": "Contribua no GitHub"
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado"
},
"header": {
"search": "Buscar jogos",
@ -29,7 +26,8 @@
"search_results": "Resultados da busca",
"settings": "Ajustes",
"home": "Início",
"version_available": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar."
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
},
"bottom_panel": {
"no_downloads_in_progress": "Sem downloads em andamento",
@ -100,7 +98,25 @@
"screenshot": "Captura de tela {{number}}",
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"downloader": "Downloader"
"downloader": "Downloader",
"select_executable": "Selecionar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos",
"options": "Opções",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
"executable_section_description": "O caminho do arquivo que será executado ao clicar em \"Jogar\"",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Confira atualizações ou versões diferentes para este mesmo título",
"danger_zone_section_title": "Zona de perigo",
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada"
},
"activation": {
"title": "Ativação",
@ -118,7 +134,7 @@
"verifying": "Verificando…",
"completed_at": "Concluído em {{date}}",
"completed": "Concluído",
"removed": "Não baixado",
"removed": "Cancelado",
"download_again": "Baixar novamente",
"cancel": "Cancelar",
"filter": "Filtrar jogos baixados",
@ -130,7 +146,13 @@
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…",
"install": "Instalar"
"install": "Instalar",
"download_in_progress": "Baixando agora",
"queued_downloads": "Na fila",
"downloads_completed": "Completo",
"queued": "Na fila",
"no_downloads_title": "Nada por aqui…",
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
},
"settings": {
"downloads_path": "Diretório dos downloads",
@ -145,6 +167,7 @@
"launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral",
"behavior": "Comportamento",
"download_sources": "Fontes de download",
"language": "Idioma",
"real_debrid_api_token": "Token de API",
"enable_real_debrid": "Habilitar Real-Debrid",
@ -154,7 +177,25 @@
"real_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine a Real-Debrid",
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"save_changes": "Salvar mudanças",
"changes_saved": "Ajustes salvos com sucesso"
"changes_saved": "Ajustes salvos com sucesso",
"download_sources_description": "Hydra vai buscar links de download em todas as fonte habilitadas. A URL da fonte deve ser um link direto para um arquivo .json contendo uma lista de links.",
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
"download_options_zero": "Sem downloads disponíveis",
"download_options_one": "{{countFormatted}} download disponível",
"download_options_other": "{{countFormatted}} downloads disponíveis",
"download_source_url": "URL da fonte",
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"sync_download_sources": "Sincronizar",
"removed_download_source": "Fonte removida",
"added_download_source": "Fonte adicionada",
"download_sources_synced": "As fontes foram sincronizadas"
},
"notifications": {
"download_complete": "Download concluído",
@ -181,5 +222,8 @@
},
"modal": {
"close": "Botão de fechar"
},
"forms": {
"toggle_password_visibility": "Alternar visibilidade da senha"
}
}

View File

@ -1,23 +1,6 @@
import { app } from "electron";
import path from "node:path";
export const repackersOn1337x = [
"DODI",
"FitGirl",
"0xEMPRESS",
"KaOsKrew",
"TinyRepacks",
] as const;
export const repackers = [
...repackersOn1337x,
"Xatab",
"TinyRepacks",
"CPG",
"GOG",
"onlinefix",
] as const;
export const defaultDownloadsPath = app.getPath("downloads");
export const databasePath = path.join(

View File

@ -1,5 +1,12 @@
import { DataSource } from "typeorm";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
} from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants";
@ -10,7 +17,14 @@ export const createDataSource = (
) =>
new DataSource({
type: "better-sqlite3",
entities: [Game, Repack, UserPreferences, GameShopCache],
entities: [
Game,
Repack,
UserPreferences,
GameShopCache,
DownloadSource,
DownloadQueue,
],
synchronize: true,
database: databasePath,
...options,

View File

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared";
@Entity("download_source")
export class DownloadSource {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true, unique: true })
url: string;
@Column("text")
name: string;
@Column("text", { nullable: true })
etag: string | null;
@Column("int", { default: 0 })
downloadCount: number;
@Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus;
@OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -12,6 +12,7 @@ import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
export class Game {
@ -63,10 +64,19 @@ export class Game {
@Column("float", { default: 0 })
fileSize: number;
@OneToOne(() => Repack, { nullable: true })
@Column("text", { nullable: true })
uri: string | null;
/**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn()
repack: Repack;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false })
isDeleted: boolean;

View File

@ -2,3 +2,5 @@ export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";

View File

@ -4,7 +4,9 @@ import {
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from "typeorm";
import { DownloadSource } from "./download-source.entity";
@Entity("repack")
export class Repack {
@ -17,7 +19,10 @@ export class Repack {
@Column("text", { unique: true })
magnet: string;
@Column("int")
/**
* @deprecated
*/
@Column("int", { nullable: true })
page: number;
@Column("text")
@ -29,6 +34,9 @@ export class Repack {
@Column("datetime")
uploadDate: Date | string;
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource;
@CreateDateColumn()
createdAt: Date;

View File

@ -1,4 +1,4 @@
import { AppUpdaterEvents } from "@types";
import { AppUpdaterEvent } from "@types";
import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater";
import { WindowManager } from "@main/services";
@ -6,12 +6,18 @@ import { app } from "electron";
const { autoUpdater } = updater;
const sendEvent = (event: AppUpdaterEvents) => {
const sendEvent = (event: AppUpdaterEvent) => {
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
};
const sendEventsForDebug = false;
const isAutoInstallAvailable =
process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null;
const mockValuesForDebug = () => {
sendEvent({ type: "update-available", info: { version: "1.3.0" } });
sendEvent({ type: "update-downloaded" });
};
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
@ -24,10 +30,13 @@ const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
});
if (app.isPackaged) {
autoUpdater.autoDownload = isAutoInstallAvailable;
autoUpdater.checkForUpdates();
} else {
} else if (sendEventsForDebug) {
mockValuesForDebug();
}
return isAutoInstallAvailable;
};
registerEvent("checkForUpdates", checkForUpdates);

View File

@ -1,95 +1,36 @@
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types";
import { getSteamAppAsset } from "@main/helpers";
import type { CatalogueEntry, GameShop } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { requestSteam250 } from "@main/services";
const repacks = stateManager.getValue("repacks");
const getStringForLookup = (index: number): string => {
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
import { RepacksManager, requestSteam250 } from "@main/services";
import { formatName } from "@shared";
const resultSize = 12;
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
if (!repacks.length) return [];
if (category === "trending") {
return getTrendingCatalogue(resultSize);
}
return getRecentlyAddedCatalogue(resultSize);
};
const getTrendingCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
const trendingGames = await requestSteam250("/90day");
for (
let i = 0;
i < trendingGames.length && results.length < resultSize;
i++
) {
if (!trendingGames[i]) continue;
const { title, objectID } = trendingGames[i]!;
const repacks = searchRepacks(title);
if (title && repacks.length) {
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
results.push({ ...catalogueEntry, repacks });
}
}
return results;
};
const getRecentlyAddedCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
for (let i = 0; results.length < resultSize; i++) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
for (let i = 0; i < resultSize; i++) {
if (!trendingGames[i]) {
i++;
continue;
}
const games = searchGames({ query: stringForLookup });
const { title, objectID } = trendingGames[i]!;
const repacks = RepacksManager.search({ query: formatName(title) });
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
results.push({ ...catalogueEntry, repacks });
}
return results.slice(0, resultSize);
return results;
};
registerEvent("getCatalogue", getCatalogue);

View File

@ -4,9 +4,9 @@ import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
import { steamGamesWorker } from "@main/workers";
const getLocalizedSteamAppDetails = (
const getLocalizedSteamAppDetails = async (
objectID: string,
language: string
): Promise<ShopDetails | null> => {
@ -14,20 +14,22 @@ const getLocalizedSteamAppDetails = (
return getSteamAppDetails(objectID, language);
}
return getSteamAppDetails(objectID, language).then((localizedAppDetails) => {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
return getSteamAppDetails(objectID, language).then(
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
name: steamGame.name,
};
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
name: steamGame.name,
};
}
return null;
}
return null;
});
);
};
const getGameShopDetails = async (

View File

@ -1,39 +1,28 @@
import type { CatalogueEntry, GameShop } from "@types";
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
import { stateManager } from "@main/state-manager";
import { getSteamAppAsset } from "@main/helpers";
const steamGames = stateManager.getValue("steamGames");
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const results: CatalogueEntry[] = [];
const steamGames = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
);
let i = 0 + cursor;
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
while (results.length < take) {
const game = steamGames[i];
const repacks = searchRepacks(game.name);
if (repacks.length) {
results.push({
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
repacks,
});
}
i++;
}
return { results, cursor: i };
return {
results: entries,
cursor: cursor + entries.length,
};
};
registerEvent("getGames", getGames);

View File

@ -3,21 +3,34 @@ import { shuffle } from "lodash-es";
import { getSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { searchSteamGames } from "../helpers/search-games";
import type { Steam250Game } from "@types";
const state = { games: Array<Steam250Game>(), index: 0 };
const filterGames = async (games: Steam250Game[]) => {
const results: Steam250Game[] = [];
for (const game of games) {
const catalogue = await searchSteamGames({ query: game.title });
if (catalogue.length) {
const [steamGame] = catalogue;
if (steamGame.repacks.length) {
results.push(game);
}
}
}
return results;
};
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
if (state.games.length == 0) {
const steam250List = await getSteam250List();
const filteredSteam250List = steam250List.filter((game) => {
const repacks = searchRepacks(game.title);
const catalogue = searchGames({ query: game.title });
return repacks.length && catalogue.length;
});
const filteredSteam250List = await filterGames(steam250List);
state.games = shuffle(filteredSteam250List);
}

View File

@ -1,11 +1,9 @@
import { searchRepacks } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
import { registerEvent } from "../register-event";
const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent,
query: string
) => {
return searchRepacks(query);
};
) => RepacksManager.search({ query });
registerEvent("searchGameRepacks", searchGameRepacks);

View File

@ -1,12 +1,10 @@
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
import { searchSteamGames } from "../helpers/search-games";
import { CatalogueEntry } from "@types";
const searchGamesEvent = async (
_event: Electron.IpcMainInvokeEvent,
query: string
): Promise<CatalogueEntry[]> => {
return searchGames({ query, take: 12 });
};
): Promise<CatalogueEntry[]> => searchSteamGames({ query, limit: 12 });
registerEvent("searchGames", searchGamesEvent);

View File

@ -0,0 +1,42 @@
import { registerEvent } from "../register-event";
import { dataSource } from "@main/data-source";
import { DownloadSource } from "@main/entity";
import axios from "axios";
import { downloadSourceSchema } from "../helpers/validators";
import { insertDownloadsFromSource } from "@main/helpers";
import { RepacksManager } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const downloadSource = await dataSource.transaction(
async (transactionalEntityManager) => {
const downloadSource = await transactionalEntityManager
.getRepository(DownloadSource)
.save({
url,
name: source.name,
downloadCount: source.downloads.length,
});
await insertDownloadsFromSource(
transactionalEntityManager,
downloadSource,
source.downloads
);
return downloadSource;
}
);
await RepacksManager.updateRepacks();
return downloadSource;
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@ -0,0 +1,16 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
return downloadSourceRepository
.createQueryBuilder("downloadSource")
.leftJoin("downloadSource.repacks", "repacks")
.orderBy("downloadSource.createdAt", "DESC")
.loadRelationCountAndMap(
"downloadSource.repackCount",
"downloadSource.repacks"
)
.getMany();
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -0,0 +1,13 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { RepacksManager } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await RepacksManager.updateRepacks();
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@ -0,0 +1,7 @@
import { registerEvent } from "../register-event";
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
fetchDownloadSourcesAndUpdate();
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@ -0,0 +1,34 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { downloadSourceRepository } from "@main/repository";
import { downloadSourceSchema } from "../helpers/validators";
import { RepacksManager } from "@main/services";
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingSource = await downloadSourceRepository.findOne({
where: { url },
});
if (existingSource)
throw new Error("Source with the same url already exists");
const repacks = RepacksManager.repacks;
const existingUris = source.downloads
.flatMap((download) => download.uris)
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
return {
name: source.name,
downloadCount: source.downloads.length - existingUris.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@ -1,40 +1,11 @@
import flexSearch from "flexsearch";
import { orderBy } from "lodash-es";
import flexSearch from "flexsearch";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { stateManager } from "@main/state-manager";
const { Index } = flexSearch;
const repacksIndex = new Index();
const steamGamesIndex = new Index({ tokenize: "forward" });
const repacks = stateManager.getValue("repacks");
const steamGames = stateManager.getValue("steamGames");
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)));
}
for (let i = 0; i < steamGames.length; i++) {
const steamGame = steamGames[i];
steamGamesIndex.add(i, formatName(steamGame.name));
}
export const searchRepacks = (title: string): GameRepack[] => {
return orderBy(
repacksIndex
.search(formatName(title))
.map((index) => repacks.at(index as number)!),
["uploadDate"],
"desc"
);
};
import { getSteamAppAsset } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
export interface SearchGamesArgs {
query?: string;
@ -42,27 +13,29 @@ export interface SearchGamesArgs {
skip?: number;
}
export const searchGames = ({
query,
take,
skip,
}: SearchGamesArgs): CatalogueEntry[] => {
const results = steamGamesIndex
.search(formatName(query || ""), { limit: take, offset: skip })
.map((index) => {
const result = steamGames.at(index as number)!;
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
repacks: [],
});
return {
objectID: String(result.id),
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)),
repacks: searchRepacks(result.name),
};
});
export const searchSteamGames = async (
options: flexSearch.SearchOptions
): Promise<CatalogueEntry[]> => {
const steamGames = (await steamGamesWorker.run(options, {
name: "search",
})) as SteamGame[];
const result = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return orderBy(
results,
result,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
);

View File

@ -0,0 +1,14 @@
import { z } from "zod";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});

View File

@ -10,12 +10,16 @@ import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/open-external";
@ -30,6 +34,11 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/add-download-source";
import "./download-sources/remove-download-source";
import "./download-sources/sync-download-sources";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -4,14 +4,14 @@ import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager";
import { steamGamesWorker } from "@main/workers";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
title: string,
shop: GameShop,
executablePath: string | null
shop: GameShop
) => {
return gameRepository
.update(
@ -21,15 +21,14 @@ const addGameToLibrary = async (
{
shop,
status: null,
executablePath,
isDeleted: false,
}
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -41,7 +40,6 @@ const addGameToLibrary = async (
iconUrl,
objectID,
shop,
executablePath,
})
.then(() => {
if (iconUrl) {

View File

@ -0,0 +1,29 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
id: number
): Promise<boolean> => {
const game = await gameRepository.findOne({
where: { id, executablePath: Not(IsNull()) },
});
if (game) {
const filePath = game.executablePath;
const options = { filePath, name: game.title };
return createDesktopShortcut({
windows: options,
linux: options,
osx: options,
});
}
return false;
};
registerEvent("createGameShortcut", createGameShortcut);

View File

@ -1,8 +1,6 @@
import path from "node:path";
import fs from "node:fs";
import { In } from "typeorm";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
@ -14,11 +12,18 @@ const deleteGameFolder = async (
gameId: number
): Promise<void> => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: In(["removed", "complete"]),
isDeleted: false,
},
where: [
{
id: gameId,
isDeleted: false,
status: "removed",
},
{
id: gameId,
progress: 1,
isDeleted: false,
},
],
});
if (!game) return;
@ -30,7 +35,7 @@ const deleteGameFolder = async (
);
if (fs.existsSync(folderPath)) {
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
fs.rm(
folderPath,
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
@ -40,12 +45,21 @@ const deleteGameFolder = async (
reject();
}
const aria2ControlFilePath = `${folderPath}.aria2`;
if (fs.existsSync(aria2ControlFilePath))
fs.rmSync(aria2ControlFilePath);
resolve();
}
);
});
}
}
await gameRepository.update(
{ id: gameId },
{ downloadPath: null, folderName: null, status: null, progress: 0 }
);
};
registerEvent("deleteGameFolder", deleteGameFolder);

View File

@ -11,9 +11,6 @@ const getGameByObjectID = async (
objectID,
isDeleted: false,
},
relations: {
repack: true,
},
});
registerEvent("getGameByObjectID", getGameByObjectID);

View File

@ -1,30 +1,17 @@
import { gameRepository } from "@main/repository";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { sortBy } from "lodash-es";
const getLibrary = async () =>
gameRepository
.find({
where: {
isDeleted: false,
},
order: {
createdAt: "desc",
},
relations: {
repack: true,
},
})
.then((games) =>
sortBy(
games.map((game) => ({
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== "removed" ? 0 : 1)
)
);
gameRepository.find({
where: {
isDeleted: false,
},
relations: {
downloadQueue: true,
},
order: {
createdAt: "desc",
},
});
registerEvent("getLibrary", getLibrary);

View File

@ -0,0 +1,18 @@
import { shell } from "electron";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const openGameExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.executablePath) return;
shell.showItemInFolder(game.executablePath);
};
registerEvent("openGameExecutablePath", openGameExecutablePath);

View File

@ -0,0 +1,27 @@
import { shell } from "electron";
import path from "node:path";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
const openGameInstallerPath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.folderName || !game.downloadPath) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName!
);
shell.showItemInFolder(gamePath);
return true;
};
registerEvent("openGameInstallerPath", openGameInstallerPath);

View File

@ -44,6 +44,11 @@ const openGameInstaller = async (
return true;
}
if (process.platform === "darwin") {
shell.openPath(gamePath);
return true;
}
if (fs.lstatSync(gamePath).isFile()) {
return executeGameInstaller(gamePath);
}

View File

@ -5,7 +5,10 @@ const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
gameRepository.update({ id: gameId }, { isDeleted: true });
gameRepository.update(
{ id: gameId },
{ isDeleted: true, executablePath: null }
);
};
registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View File

@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
id: number,
executablePath: string
) => {
return gameRepository.update(
{
id,
},
{
executablePath,
}
);
};
registerEvent("updateExecutablePath", updateExecutablePath);

View File

@ -1,25 +1,31 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.cancelDownload(gameId);
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await gameRepository.update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager.getRepository(Game).update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
};
registerEvent("cancelGameDownload", cancelGameDownload);

View File

@ -1,13 +1,24 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await DownloadManager.pauseDownload();
await gameRepository.update({ id: gameId }, { status: "paused" });
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.pauseDownload();
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "paused" });
});
};
registerEvent("pauseGameDownload", pauseGameDownload);

View File

@ -5,7 +5,7 @@ import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { Game } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -16,7 +16,6 @@ const resumeGameDownload = async (
id: gameId,
isDeleted: false,
},
relations: { repack: true },
});
if (!game) return;
@ -31,6 +30,14 @@ const resumeGameDownload = async (
await DownloadManager.resumeDownload(game);
await transactionalEntityManager
.getRepository(DownloadQueue)
.delete({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "active" });

View File

@ -1,12 +1,17 @@
import { gameRepository, repackRepository } from "@main/repository";
import {
downloadQueueRepository,
gameRepository,
repackRepository,
} from "@main/repository";
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { DownloadManager } from "@main/services";
import { stateManager } from "@main/state-manager";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -20,7 +25,6 @@ const startGameDownload = async (
objectID,
shop,
},
relations: { repack: true },
}),
repackRepository.findOne({
where: {
@ -49,14 +53,14 @@ const startGameDownload = async (
bytesDownloaded: 0,
downloadPath,
downloader,
repack: { id: repackId },
uri: repack.magnet,
isDeleted: false,
}
);
} else {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const steamGame = await steamGamesWorker.run(Number(objectID), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -71,7 +75,7 @@ const startGameDownload = async (
shop,
status: "active",
downloadPath,
repack: { id: repackId },
uri: repack.magnet,
})
.then((result) => {
if (iconUrl) {
@ -88,9 +92,11 @@ const startGameDownload = async (
where: {
objectID,
},
relations: { repack: true },
});
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.startDownload(updatedGame!);
};

View File

@ -0,0 +1,75 @@
import { dataSource } from "@main/data-source";
import { DownloadSource, Repack } from "@main/entity";
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
import { chunk } from "lodash-es";
import type { EntityManager } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { z } from "zod";
export const insertDownloadsFromSource = async (
trx: EntityManager,
downloadSource: DownloadSource,
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
) => {
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({
title: download.title,
magnet: download.uris[0],
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSource: { id: downloadSource.id },
})
);
const downloadsChunks = chunk(repacks, 800);
for (const chunk of downloadsChunks) {
await trx
.getRepository(Repack)
.createQueryBuilder()
.insert()
.values(chunk)
.updateEntity(false)
.orIgnore()
.execute();
}
};
export const fetchDownloadSourcesAndUpdate = async () => {
const downloadSources = await downloadSourceRepository.find({
order: {
id: "desc",
},
});
const results = await downloadSourceWorker.run(downloadSources, {
name: "getUpdatedRepacks",
});
await dataSource.transaction(async (transactionalEntityManager) => {
for (const result of results) {
if (result.etag !== null) {
await transactionalEntityManager.getRepository(DownloadSource).update(
{ id: result.id },
{
etag: result.etag,
status: result.status,
downloadCount: result.downloads.length,
}
);
await insertDownloadsFromSource(
transactionalEntityManager,
result,
result.downloads
);
}
}
await RepacksManager.updateRepacks();
});
};

View File

@ -1,98 +0,0 @@
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

@ -1,56 +0,0 @@
/* 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 onlinefixFormatter = (title: string) =>
title.replace("по сети", "").trim();
export const gogFormatter = (title: string) =>
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");

View File

@ -1,48 +1,5 @@
import {
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
empressFormatter,
kaosKrewFormatter,
fitGirlFormatter,
removeDuplicateSpaces,
dodiFormatter,
removeTrash,
xatabFormatter,
tinyRepacksFormatter,
gogFormatter,
onlinefixFormatter,
} from "./formatters";
import { 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,
onlinefix: onlinefixFormatter,
};
import axios from "axios";
import UserAgent from "user-agents";
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
@ -88,5 +45,17 @@ export const steamUrlBuilder = {
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
export * from "./formatters";
export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.then((response) => response.data);
};
export * from "./ps";
export * from "./download-source";

View File

@ -3,15 +3,11 @@ import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import {
DownloadManager,
logger,
resolveDatabaseUpdates,
WindowManager,
} from "@main/services";
import { DownloadManager, logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
const { autoUpdater } = updater;
autoUpdater.setFeedURL({
@ -51,27 +47,24 @@ if (process.defaultApp) {
// 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(() => {
app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
protocol.handle("hydra", (request) =>
net.fetch("file://" + request.url.slice("hydra://".length))
);
dataSource.initialize().then(async () => {
await dataSource.runMigrations();
await dataSource.initialize();
await dataSource.runMigrations();
await resolveDatabaseUpdates();
await import("./main");
await import("./main");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
app.on("browser-window-created", (_, window) => {

View File

@ -1,103 +1,48 @@
import { stateManager } from "./state-manager";
import { repackersOn1337x, seedsPath } from "./constants";
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import {
getNewGOGGames,
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
DownloadManager,
startMainLoop,
} from "./services";
import {
gameRepository,
downloadQueueRepository,
repackRepository,
userPreferencesRepository,
} from "./repository";
import { Repack, UserPreferences } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import fs from "node:fs";
import path from "node:path";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { orderBy } from "lodash-es";
import { SteamGame } from "@types";
import { Not } from "typeorm";
import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
import { MoreThan } from "typeorm";
startMainLoop();
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackersOn1337x) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
);
}
};
const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
track1337xUsers(existingRepacks),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromOnlineFix(
existingRepacks.filter((repack) => repack.repacker === "onlinefix")
),
]).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 (userPreferences: UserPreferences | null) => {
const repacks = repackRepository.find({
order: {
createdAt: "desc",
},
});
const steamGames = JSON.parse(
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
) as SteamGame[];
stateManager.setValue("repacks", await repacks);
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
await RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
const game = await gameRepository.findOne({
where: {
status: "active",
progress: Not(1),
isDeleted: false,
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
relations: { repack: true },
});
if (game) DownloadManager.startDownload(game);
if (nextQueueItem?.game.status === "active")
DownloadManager.startDownload(nextQueueItem.game);
const now = new Date();
fetchDownloadSourcesAndUpdate().then(async () => {
const newRepacksCount = await repackRepository.count({
where: {
createdAt: MoreThan(now),
},
});
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
});
};
userPreferencesRepository
@ -105,5 +50,5 @@ userPreferencesRepository
where: { id: 1 },
})
.then((userPreferences) => {
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences));
loadState(userPreferences);
});

View File

@ -1,75 +1,8 @@
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { app } from "electron";
import { chunk } from "lodash-es";
import path from "path";
import { In, MigrationInterface, QueryRunner, Table } from "typeorm";
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "repack_temp",
columns: [
{ name: "title", type: "varchar" },
{ name: "old_id", type: "int" },
],
}),
true
);
await queryRunner.query(
`INSERT INTO repack_temp (title, old_id) SELECT title, id FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
await queryRunner.query(
`DELETE FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "hydra.db"),
});
await updateDataSource.initialize();
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updatedRepacks = await updateRepackRepository.find({
where: {
repacker: In(["onlinefix", "Xatab"]),
},
});
const chunks = chunk(
updatedRepacks.map((repack) => {
const { id: _, ...rest } = repack;
return rest;
}),
500
);
for (const chunk of chunks) {
await queryRunner.manager
.createQueryBuilder(Repack, "repack")
.insert()
.values(chunk)
.orIgnore()
.execute();
}
await queryRunner.query(
`UPDATE game
SET repackId = (
SELECT id
from repack LEFT JOIN repack_temp ON repack_temp.title = repack.title
WHERE repack_temp.old_id = game.repackId
)
WHERE EXISTS (select old_id from repack_temp WHERE old_id = game.repackId)`
);
await queryRunner.dropTable("repack_temp");
public async up(_: QueryRunner): Promise<void> {
return;
}
public async down(_: QueryRunner): Promise<void> {

View File

@ -1,5 +1,12 @@
import { dataSource } from "./data-source";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@ -9,3 +16,8 @@ export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository =
dataSource.getRepository(DownloadSource);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);

View File

@ -1,11 +1,12 @@
import Aria2, { StatusResponse } from "aria2";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Notification } from "electron";
import { t } from "i18next";
import { Downloader } from "@shared";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
@ -14,6 +15,7 @@ import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager {
private static downloads = new Map<number, string>();
@ -65,29 +67,13 @@ export class DownloadManager {
return -1;
}
static async publishNotification() {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled && this.game) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: this.game.title,
}),
}).show();
}
}
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
return "";
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
}
private static async getRealDebridDownloadUrl() {
@ -192,22 +178,8 @@ export class DownloadManager {
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
relations: { repack: true },
});
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
}
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
@ -230,6 +202,34 @@ export class DownloadManager {
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
private static clearCurrentDownload() {
@ -245,7 +245,7 @@ export class DownloadManager {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("remove", gid);
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
@ -291,10 +291,10 @@ export class DownloadManager {
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.repack.magnet
game!.uri!
);
} else {
this.gid = await this.aria2.call("addUri", [game.repack.magnet], options);
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
}

View File

@ -1,8 +1,8 @@
import { formatName } from "@main/helpers";
import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "./repack-tracker/helpers";
import { requestWebPage } from "@main/helpers";
import { HowLongToBeatCategory } from "@types";
import { formatName } from "@shared";
export interface HowLongToBeatResult {
game_id: number;

View File

@ -1,11 +1,10 @@
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 "./download-manager";
export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";
export * from "./repacks-manager";

View File

@ -0,0 +1,72 @@
import { Notification, nativeImage } from "electron";
import { t } from "i18next";
import { parseICO } from "icojs";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
const getGameIconNativeImage = async (gameId: number) => {
try {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
});
if (!game?.iconUrl) return undefined;
const images = await parseICO(
Buffer.from(game.iconUrl.split("base64,")[1], "base64")
);
const highResIcon = images.find((image) => image.width >= 128);
if (!highResIcon) return undefined;
return nativeImage.createFromBuffer(Buffer.from(highResIcon.buffer));
} catch (err) {
return undefined;
}
};
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const icon = await getGameIconNativeImage(game.id);
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,
}),
icon,
}).show();
}
};
export const publishNewRepacksNotifications = async (count: number) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (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: count,
}),
}).show();
}
};

View File

@ -1,146 +0,0 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
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;
};
/* 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,
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
) => {
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 ?? "";
const details = await getTorrentDetails(url);
return {
title,
magnet: details.magnet,
fileSize: details.fileSize ?? "N/A",
uploadDate: details.uploadDate ?? new Date(),
repacker: user,
page,
};
})
);
};
export const getNewRepacksFromUser = async (
user: string,
existingRepacks: Repack[],
page = 1
) => {
const response = await request1337x(`/user/${user}/${page}`);
const { window } = new JSDOM(response);
const repacks = await extractTorrentsFromDocument(
page,
user,
window.document
);
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromUser(user, existingRepacks, page + 1);
};

View File

@ -1,96 +0,0 @@
import { JSDOM, VirtualConsole } from "jsdom";
import { requestWebPage, savePage } from "./helpers";
import { Repack } from "@main/entity";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
const virtualConsole = new VirtualConsole();
const getUploadDate = (document: Document) => {
const $modifiedTime = document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
if ($modifiedTime) return $modifiedTime.content;
const $publishedTime = document.querySelector(
'[property="article:published_time"]'
) as HTMLMetaElement;
return $publishedTime.content;
};
const getDownloadLink = (document: Document) => {
const $latestDownloadButton = document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
if ($latestDownloadButton) return $latestDownloadButton.href;
const $downloadButton = document.querySelector(
".download-btn"
) as HTMLAnchorElement;
if (!$downloadButton) return null;
return $downloadButton.href;
};
const getMagnet = (downloadLink: string) => {
if (downloadLink.startsWith("http")) {
const { searchParams } = new URL(downloadLink);
return Buffer.from(searchParams.get("url")!, "base64").toString("utf-8");
}
return downloadLink;
};
const getGOGGame = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data, { virtualConsole });
const downloadLink = getDownloadLink(window.document);
if (!downloadLink) return null;
const $em = window.document.querySelector("p em");
if (!$em) return null;
const fileSize = $em.textContent!.split("Size: ").at(1);
return {
fileSize: fileSize ?? "N/A",
uploadDate: new Date(getUploadDate(window.document)),
repacker: "GOG",
magnet: getMagnet(downloadLink),
page: 1,
};
};
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
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: QueryDeepPartialEntity<Repack>[] = [];
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) {
const game = await getGOGGame(href);
if (game) repacks.push({ ...game, title });
}
}
if (repacks.length) await savePage(repacks);
}
};

View File

@ -1,40 +0,0 @@
import axios from "axios";
import UserAgent from "user-agents";
import type { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
export const savePage = async (repacks: QueryDeepPartialEntity<Repack>[]) =>
Promise.all(
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
);
export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.then((response) => response.data);
};
export const decodeNonUtf8Response = async (res: Response) => {
const contentType = res.headers.get("content-type");
if (!contentType) return res.text();
const charset = contentType.substring(contentType.indexOf("charset=") + 8);
const text = await res.arrayBuffer().then((ab) => {
const dataView = new DataView(ab);
const decoder = new TextDecoder(charset);
return decoder.decode(dataView);
});
return text;
};

View File

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

View File

@ -1,157 +0,0 @@
import { Repack } from "@main/entity";
import { decodeNonUtf8Response, savePage } from "./helpers";
import { logger } from "../logger";
import { JSDOM } from "jsdom";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
const worker = createWorker({});
import makeFetchCookie from "fetch-cookie";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
const ONLINE_FIX_URL = "https://online-fix.me/";
let totalPages = 1;
export const getNewRepacksFromOnlineFix = async (
existingRepacks: Repack[] = [],
page = 1,
cookieJar = new makeFetchCookie.toughCookie.CookieJar()
): Promise<void> => {
const hasCredentials =
import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME &&
import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD;
if (!hasCredentials) return;
const http = makeFetchCookie(fetch, cookieJar);
if (page === 1) {
await http(ONLINE_FIX_URL);
const preLogin =
((await http("https://online-fix.me/engine/ajax/authtoken.php", {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
Referer: ONLINE_FIX_URL,
},
}).then((res) => res.json())) as {
field: string;
value: string;
}) || undefined;
if (!preLogin.field || !preLogin.value) return;
const params = new URLSearchParams({
login_name: import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME,
login_password: import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD,
login: "submit",
[preLogin.field]: preLogin.value,
});
await http(ONLINE_FIX_URL, {
method: "POST",
headers: {
Referer: ONLINE_FIX_URL,
Origin: ONLINE_FIX_URL,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
}
const pageParams = page > 1 ? `${`/page/${page}`}` : "";
const home = await http(`https://online-fix.me${pageParams}`).then((res) =>
decodeNonUtf8Response(res)
);
const document = new JSDOM(home).window.document;
const repacks: QueryDeepPartialEntity<Repack>[] = [];
const articles = Array.from(document.querySelectorAll(".news"));
if (page == 1) {
totalPages = Number(
document.querySelector("nav > a:nth-child(13)")?.textContent
);
}
try {
await Promise.all(
articles.map(async (article) => {
const gameLink = article.querySelector("a")?.getAttribute("href");
if (!gameLink) return;
const gamePage = await http(gameLink).then((res) =>
decodeNonUtf8Response(res)
);
const gameDocument = new JSDOM(gamePage).window.document;
const torrentButtons = Array.from(
gameDocument.querySelectorAll("a")
).filter((a) => a.textContent?.includes("Torrent"));
const torrentPrePage = torrentButtons[0]?.getAttribute("href");
if (!torrentPrePage) return;
const torrentPage = await http(torrentPrePage, {
headers: {
Referer: gameLink,
},
}).then((res) => res.text());
const torrentDocument = new JSDOM(torrentPage).window.document;
const torrentLink = torrentDocument
.querySelector("a:nth-child(2)")
?.getAttribute("href");
const torrentFile = Buffer.from(
await http(`${torrentPrePage}${torrentLink}`).then((res) =>
res.arrayBuffer()
)
);
worker.once("message", (torrent) => {
if (!torrent) return;
const { name, created } = torrent;
repacks.push({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
page: 1,
repacker: "onlinefix",
title: name,
uploadDate: created,
});
});
worker.postMessage(torrentFile);
})
);
} catch (err: unknown) {
logger.error((err as Error).message, {
method: "getNewRepacksFromOnlineFix",
});
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromOnlineFix(existingRepacks, page + 1, cookieJar);
};

View File

@ -1,120 +0,0 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { requestWebPage, savePage } from "./helpers";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
import type { Instance } from "parse-torrent";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
import { getFileBuffer } from "@main/helpers";
const worker = createWorker({});
worker.setMaxListeners(11);
let totalPages = 1;
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 getXatabRepack = (
url: string
): Promise<{ fileSize: string; magnet: string; uploadDate: Date } | null> => {
return new Promise((resolve) => {
(async () => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const { document } = window;
const $uploadDate = document.querySelector(".entry__date");
const $downloadButton = document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) return resolve(null);
worker.once("message", (torrent: Instance | null) => {
if (!torrent) return resolve(null);
resolve({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
uploadDate: formatXatabDate($uploadDate!.textContent!),
});
});
const buffer = await getFileBuffer($downloadButton.href);
worker.postMessage(buffer);
})();
});
};
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: QueryDeepPartialEntity<Repack>[] = [];
if (page === 1) {
totalPages = Number(
window.document.querySelector(
"#bottom-nav > div.pagination > a:nth-child(12)"
)?.textContent
);
}
const repacksFromPage = Array.from(
window.document.querySelectorAll(".entry__title a")
).map(($a) => {
return getXatabRepack(($a as HTMLAnchorElement).href)
.then((repack) => {
if (repack) {
repacks.push({
title: $a.textContent!,
repacker: "Xatab",
...repack,
page,
});
}
})
.catch((err: unknown) => {
logger.error((err as Error).message, {
method: "getNewRepacksFromXatab",
});
});
});
await Promise.all(repacksFromPage);
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromXatab(existingRepacks, page + 1);
};

View File

@ -0,0 +1,44 @@
import { repackRepository } from "@main/repository";
import { formatName } from "@shared";
import { CatalogueEntry, GameRepack } from "@types";
import flexSearch from "flexsearch";
export class RepacksManager {
public static repacks: GameRepack[] = [];
private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() {
this.repacks = await repackRepository.find({
order: {
createdAt: "DESC",
},
});
for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i);
}
this.repacksIndex = new flexSearch.Index();
for (let i = 0; i < this.repacks.length; i++) {
const repack = this.repacks[i];
const formattedTitle = formatName(repack.title);
this.repacksIndex.add(i, formattedTitle);
}
}
public static search(options: flexSearch.SearchOptions) {
return this.repacksIndex
.search({ ...options, query: formatName(options.query ?? "") })
.map((index) => this.repacks[index]);
}
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
return entries.map((entry) => {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
});
}
}

View File

@ -30,6 +30,6 @@ export const getSteamAppDetails = async (
})
.catch((err) => {
logger.error(err, { method: "getSteamAppDetails" });
throw new Error(err);
return null;
});
};

View File

@ -1,33 +0,0 @@
import path from "node:path";
import { app } from "electron";
import { chunk } from "lodash-es";
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "hydra.db"),
});
return updateDataSource.initialize().then(async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepacks = await updateRepackRepository.find();
const updateRepacksChunks = chunk(updateRepacks, 800);
for (const chunk of updateRepacksChunks) {
await repackRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View File

@ -5,6 +5,7 @@ import {
MenuItemConstructorOptions,
Tray,
app,
nativeImage,
shell,
} from "electron";
import { is } from "@electron-toolkit/utils";
@ -88,7 +89,16 @@ export class WindowManager {
}
public static createSystemTray(language: string) {
const tray = new Tray(trayIcon);
let tray;
if (process.platform === "darwin") {
const macIcon = nativeImage
.createFromPath(trayIcon)
.resize({ width: 24, height: 24 });
tray = new Tray(macIcon);
} else {
tray = new Tray(trayIcon);
}
const updateSystemTray = async () => {
const games = await gameRepository.find({
@ -149,9 +159,14 @@ export class WindowManager {
return contextMenu;
};
const showContextMenu = async () => {
const contextMenu = await updateSystemTray();
tray.popUpContextMenu(contextMenu);
};
tray.setToolTip("Hydra");
if (process.platform === "win32" || process.platform === "linux") {
if (process.platform !== "darwin") {
tray.addListener("click", () => {
if (this.mainWindow) {
if (WindowManager.mainWindow?.isMinimized())
@ -164,10 +179,10 @@ export class WindowManager {
this.createMainWindow();
});
tray.addListener("right-click", async () => {
const contextMenu = await updateSystemTray();
tray.popUpContextMenu(contextMenu);
});
tray.addListener("right-click", showContextMenu);
} else {
tray.addListener("click", showContextMenu);
tray.addListener("right-click", showContextMenu);
}
}
}

View File

@ -1,30 +0,0 @@
import type { Repack } from "@main/entity";
import type { SteamGame } from "@types";
interface State {
repacks: Repack[];
steamGames: SteamGame[];
}
const initialState: State = {
repacks: [],
steamGames: [],
};
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();

View File

@ -0,0 +1,50 @@
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSource } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod";
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
etag: string | null;
status: DownloadSourceStatus;
};
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
const results: DownloadSourceResponse[] = [];
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
results.push({
...downloadSource,
downloads: source.downloads,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
});
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
results.push({
...downloadSource,
downloads: [],
etag: null,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
return results;
};

18
src/main/workers/index.ts Normal file
View File

@ -0,0 +1,18 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import downloadSourceWorkerPath from "./download-source.worker?modulePath";
import Piscina from "piscina";
import { seedsPath } from "@main/constants";
export const steamGamesWorker = new Piscina({
filename: steamGamesWorkerPath,
workerData: {
steamGamesPath: path.join(seedsPath, "steam-games.json"),
},
});
export const downloadSourceWorker = new Piscina({
filename: downloadSourceWorkerPath,
});

View File

@ -0,0 +1,38 @@
import { SteamGame } from "@types";
import { orderBy, slice } from "lodash-es";
import flexSearch from "flexsearch";
import fs from "node:fs";
import { formatName } from "@shared";
import { workerData } from "node:worker_threads";
const steamGamesIndex = new flexSearch.Index({
tokenize: "reverse",
});
const { steamGamesPath } = workerData;
const data = fs.readFileSync(steamGamesPath, "utf-8");
const steamGames = JSON.parse(data) as SteamGame[];
for (let i = 0; i < steamGames.length; i++) {
const steamGame = steamGames[i];
const formattedName = formatName(steamGame.name);
steamGamesIndex.add(i, formattedName);
}
export const search = (options: flexSearch.SearchOptions) => {
const results = steamGamesIndex.search(options);
const games = results.map((index) => steamGames[index]);
return orderBy(games, ["name"], ["asc"]);
};
export const getById = (id: number) =>
steamGames.find((game) => game.id === id);
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
slice(steamGames, offset, offset + limit);

View File

@ -1,14 +0,0 @@
import { parentPort } from "worker_threads";
import parseTorrent from "parse-torrent";
const port = parentPort;
if (!port) throw new Error("IllegalState");
port.on("message", async (buffer: Buffer) => {
try {
const torrent = await parseTorrent(buffer);
port.postMessage(torrent);
} catch (err) {
port.postMessage(null);
}
});

View File

@ -3,7 +3,6 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
CatalogueCategory,
GameShop,
DownloadProgress,
UserPreferences,
@ -32,8 +31,7 @@ contextBridge.exposeInMainWorld("electron", {
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getCatalogue: () => ipcRenderer.invoke("getCatalogue"),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@ -52,23 +50,30 @@ contextBridge.exposeInMainWorld("electron", {
authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
/* Library */
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string
) =>
ipcRenderer.invoke(
"addGameToLibrary",
objectID,
title,
shop,
executablePath
),
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
createGameShortcut: (id: number) =>
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string) =>
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGameInstallerPath: (gameId: number) =>
ipcRenderer.invoke("openGameInstallerPath", gameId),
openGameExecutablePath: (gameId: number) =>
ipcRenderer.invoke("openGameExecutablePath", gameId),
openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),

View File

@ -0,0 +1,12 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const badge = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
});

View File

@ -0,0 +1,14 @@
import React from "react";
import * as styles from "./badge.css";
export interface BadgeProps {
children: React.ReactNode;
}
export function Badge({ children }: BadgeProps) {
return (
<div className={styles.badge}>
<span>{children}</span>
</div>
);
}

View File

@ -55,4 +55,15 @@ export const button = styleVariants({
color: "#c0c1c7",
},
],
danger: [
base,
{
border: `solid 1px #a31533`,
backgroundColor: "transparent",
color: "white",
":hover": {
backgroundColor: "#a31533",
},
},
],
});

View File

@ -69,16 +69,7 @@ export const downloadOptions = style({
padding: "0",
gap: `${SPACING_UNIT}px`,
flexWrap: "wrap",
});
export const downloadOption = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
listStyle: "none",
});
export const specifics = style({

View File

@ -5,6 +5,7 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -39,8 +40,8 @@ export function GameCard({ game, ...props }: GameCardProps) {
{uniqueRepackers.length > 0 ? (
<ul className={styles.downloadOptions}>
{uniqueRepackers.map((repacker) => (
<li key={repacker} className={styles.downloadOption}>
<span>{repacker}</span>
<li key={repacker}>
<Badge>{repacker}</Badge>
</li>
))}
</ul>

View File

@ -0,0 +1,77 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { SyncIcon } from "@primer/octicons-react";
import { Link } from "../link/link";
import * as styles from "./header.css";
import { AppUpdaterEvent } from "@types";
export const releasesPageUrl =
"https://github.com/hydralauncher/hydra/releases/latest";
export function AutoUpdateSubHeader() {
const [isReadyToInstall, setIsReadyToInstall] = useState(false);
const [newVersion, setNewVersion] = useState<string | null>(null);
const [isAutoInstallAvailable, setIsAutoInstallAvailable] = useState(false);
const { t } = useTranslation("header");
const handleClickInstallUpdate = () => {
window.electron.restartAndInstallUpdate();
};
useEffect(() => {
const unsubscribe = window.electron.onAutoUpdaterEvent(
(event: AppUpdaterEvent) => {
if (event.type == "update-available") {
setNewVersion(event.info.version);
}
if (event.type == "update-downloaded") {
setIsReadyToInstall(true);
}
}
);
window.electron.checkForUpdates().then((isAutoInstallAvailable) => {
setIsAutoInstallAvailable(isAutoInstallAvailable);
});
return () => {
unsubscribe();
};
}, []);
if (!newVersion) return null;
if (!isAutoInstallAvailable) {
return (
<header className={styles.subheader}>
<Link to={releasesPageUrl} className={styles.newVersionLink}>
<SyncIcon size={12} />
<small>
{t("version_available_download", { version: newVersion })}
</small>
</Link>
</header>
);
}
if (isReadyToInstall) {
return (
<header className={styles.subheader}>
<button
type="button"
className={styles.newVersionButton}
onClick={handleClickInstallUpdate}
>
<SyncIcon size={12} />
<small>
{t("version_available_install", { version: newVersion })}
</small>
</button>
</header>
);
}
return null;
}

View File

@ -157,9 +157,17 @@ export const newVersionButton = style({
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.body,
borderBottom: "1px solid transparent",
fontSize: "13px",
":hover": {
borderBottom: `1px solid ${vars.color.body}`,
textDecoration: "underline",
cursor: "pointer",
},
});
export const newVersionLink = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: "#8e919b",
fontSize: "13px",
});

View File

@ -1,18 +1,13 @@
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
ArrowLeftIcon,
SearchIcon,
SyncIcon,
XIcon,
} from "@primer/octicons-react";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css";
import { clearSearch } from "@renderer/features";
import { AppUpdaterEvents } from "@types";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
export interface HeaderProps {
onSearch: (query: string) => void;
@ -40,9 +35,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const [isFocused, setIsFocused] = useState(false);
const [showUpdateSubheader, setShowUpdateSubheader] = useState(false);
const [newVersion, setNewVersion] = useState("");
const { t } = useTranslation("header");
const title = useMemo(() => {
@ -58,30 +50,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
}
}, [location.pathname, search, dispatch]);
const handleClickRestartAndUpdate = () => {
window.electron.restartAndInstallUpdate();
};
useEffect(() => {
const unsubscribe = window.electron.onAutoUpdaterEvent(
(event: AppUpdaterEvents) => {
if (event.type == "update-available") {
setNewVersion(event.info.version || "");
}
if (event.type == "update-downloaded") {
setShowUpdateSubheader(true);
}
}
);
window.electron.checkForUpdates();
return () => {
unsubscribe();
};
}, []);
const focusInput = () => {
setIsFocused(true);
inputRef.current?.focus();
@ -103,7 +71,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
isWindows: window.electron.platform === "win32",
})}
>
<div className={styles.section}>
<section className={styles.section}>
<button
type="button"
className={styles.backButton({
@ -122,7 +90,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
>
{title}
</h3>
</div>
</section>
<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>
@ -158,18 +126,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
</div>
</section>
</header>
{showUpdateSubheader && (
<header className={styles.subheader}>
<button
type="button"
className={styles.newVersionButton}
onClick={handleClickRestartAndUpdate}
>
<SyncIcon size={12} />
<small>{t("version_available", { version: newVersion })}</small>
</button>
</header>
)}
<AutoUpdateSubHeader />
</>
);
}

View File

@ -23,6 +23,7 @@ export const heroMedia = style({
width: "100%",
height: "100%",
transition: "all ease 0.2s",
imageRendering: "revert",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.02)",

View File

@ -54,7 +54,7 @@ export function Hero() {
>
<div className={styles.backdrop}>
<img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
alt={FEATURED_GAME_TITLE}
className={styles.heroMedia}
/>

View File

@ -8,5 +8,6 @@ export * from "./sidebar/sidebar";
export * from "./text-field/text-field";
export * from "./checkbox-field/checkbox-field";
export * from "./link/link";
export * from "./select/select";
export * from "./select-field/select-field";
export * from "./toast/toast";
export * from "./badge/badge";

View File

@ -2,26 +2,27 @@ import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const fadeIn = keyframes({
"0%": { opacity: 0 },
export const scaleFadeIn = keyframes({
"0%": { opacity: "0", scale: "0.5" },
"100%": {
opacity: 1,
opacity: "1",
scale: "1",
},
});
export const fadeOut = keyframes({
"0%": { opacity: 1 },
export const scaleFadeOut = keyframes({
"0%": { opacity: "1", scale: "1" },
"100%": {
opacity: 0,
opacity: "0",
scale: "0.5",
},
});
export const modal = recipe({
base: {
animationName: fadeIn,
animationDuration: "0.3s",
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
backgroundColor: vars.color.background,
borderRadius: "5px",
borderRadius: "4px",
maxWidth: "600px",
color: vars.color.body,
maxHeight: "100%",
@ -33,8 +34,14 @@ export const modal = recipe({
variants: {
closing: {
true: {
animationName: fadeOut,
opacity: 0,
animationName: scaleFadeOut,
opacity: "0",
},
},
large: {
true: {
width: "800px",
maxWidth: "800px",
},
},
},

View File

@ -12,6 +12,7 @@ export interface ModalProps {
title: string;
description?: string;
onClose: () => void;
large?: boolean;
children: React.ReactNode;
}
@ -20,6 +21,7 @@ export function Modal({
title,
description,
onClose,
large,
children,
}: ModalProps) {
const [isClosing, setIsClosing] = useState(false);
@ -88,7 +90,7 @@ export function Modal({
return createPortal(
<Backdrop isClosing={isClosing}>
<div
className={styles.modal({ closing: isClosing })}
className={styles.modal({ closing: isClosing, large })}
role="dialog"
aria-labelledby={title}
aria-describedby={description}

View File

@ -49,9 +49,6 @@ export const option = style({
fontSize: vars.size.body,
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
});
export const label = style({

View File

@ -1,6 +1,6 @@
import { useId, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./select.css";
import * as styles from "./select-field.css";
export interface SelectProps
extends React.DetailedHTMLProps<
@ -12,7 +12,7 @@ export interface SelectProps
options?: { key: string; value: string; label: string }[];
}
export function Select({
export function SelectField({
value,
label,
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],

View File

@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import type { Game } from "@types";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { routes } from "./routes";
@ -25,7 +25,7 @@ export function Sidebar() {
const { library, updateLibrary } = useLibrary();
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
const [isResizing, setIsResizing] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(
@ -36,6 +36,8 @@ export function Sidebar() {
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
useEffect(() => {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
@ -99,9 +101,7 @@ export function Sidebar() {
};
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === "paused") return t("paused", { title: game.title });
const getGameTitle = (game: LibraryGame) => {
if (lastPacket?.game.id === game.id) {
return t("downloading", {
title: game.title,
@ -109,6 +109,12 @@ export function Sidebar() {
});
}
if (game.downloadQueue !== null) {
return t("queued", { title: game.title });
}
if (game.status === "paused") return t("paused", { title: game.title });
return game.title;
};
@ -118,6 +124,24 @@ export function Sidebar() {
}
};
const handleSidebarGameClick = (
event: React.MouseEvent,
game: LibraryGame
) => {
const path = buildGameDetailsPath(game);
if (path !== location.pathname) {
navigate(path);
}
if (event.detail == 2) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
} else {
showWarningToast(t("game_has_no_executable"));
}
}
};
return (
<aside
ref={sidebarRef}
@ -179,9 +203,7 @@ export function Sidebar() {
<button
type="button"
className={styles.menuItemButton}
onClick={() =>
handleSidebarItemClick(buildGameDetailsPath(game))
}
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img

View File

@ -42,18 +42,33 @@ export const textField = recipe({
},
});
export const textFieldInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
export const textFieldInput = recipe({
base: {
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
},
variants: {
readOnly: {
true: {
textOverflow: "inherit",
},
},
},
});
export const togglePasswordButton = style({
cursor: "pointer",
color: vars.color.muted,
padding: `${SPACING_UNIT}px`,
});

View File

@ -1,6 +1,8 @@
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./text-field.css";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
export interface TextFieldProps
extends React.DetailedHTMLProps<
@ -28,9 +30,20 @@ export function TextField({
containerProps,
...props
}: TextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
return (
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
@ -41,12 +54,27 @@ export function TextField({
>
<input
id={id}
type="text"
className={styles.textFieldInput}
className={styles.textFieldInput({ readOnly: props.readOnly })}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>
{isPasswordVisible ? (
<EyeClosedIcon size={16} />
) : (
<EyeIcon size={16} />
)}
</button>
)}
</div>
{hint && <small>{hint}</small>}

View File

@ -81,3 +81,7 @@ export const successIcon = style({
export const errorIcon = style({
color: vars.color.danger,
});
export const warningIcon = style({
color: vars.color.warning,
});

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
AlertIcon,
CheckCircleFillIcon,
XCircleFillIcon,
XIcon,
@ -11,7 +12,7 @@ import { SPACING_UNIT } from "@renderer/theme.css";
export interface ToastProps {
visible: boolean;
message: string;
type: "success" | "error";
type: "success" | "error" | "warning";
onClose: () => void;
}
@ -84,6 +85,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
)}
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
{type === "warning" && <AlertIcon className={styles.warningIcon} />}
<span style={{ fontWeight: "bold" }}>{message}</span>
</div>

View File

@ -1,8 +1,8 @@
import type {
AppUpdaterEvents,
CatalogueCategory,
AppUpdaterEvent,
CatalogueEntry,
Game,
LibraryGame,
GameRepack,
GameShop,
HowLongToBeatCategory,
@ -12,6 +12,7 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -33,7 +34,7 @@ declare global {
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getCatalogue: () => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
@ -55,11 +56,14 @@ declare global {
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string | null
shop: GameShop
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
createGameShortcut: (id: number) => Promise<boolean>;
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
@ -77,6 +81,15 @@ declare global {
autoLaunch: (enabled: boolean) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@ -92,9 +105,9 @@ declare global {
/* Auto update */
onAutoUpdaterEvent: (
cb: (event: AppUpdaterEvents) => void
cb: (event: AppUpdaterEvent) => void
) => () => Electron.IpcRenderer;
checkForUpdates: () => Promise<void>;
checkForUpdates: () => Promise<boolean>;
restartAndInstallUpdate: () => Promise<void>;
}

View File

@ -1,10 +1,10 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { Game } from "@types";
import type { LibraryGame } from "@types";
export interface LibraryState {
value: Game[];
value: LibraryGame[];
}
const initialState: LibraryState = {

View File

@ -1,4 +1,4 @@
import type { CatalogueEntry } from "@types";
import type { GameShop } from "@types";
export const steamUrlBuilder = {
library: (objectID: string) =>
@ -34,7 +34,7 @@ export const getSteamLanguage = (language: string) => {
};
export const buildGameDetailsPath = (
game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
game: { shop: GameShop; objectID: string; title: string },
params: Record<string, string> = {}
) => {
const searchParams = new URLSearchParams({ title: game.title, ...params });

View File

@ -41,24 +41,25 @@ export function useDownload() {
return updateLibrary();
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
dispatch(clearDownload());
updateLibrary();
};
const removeGameInstaller = async (gameId: number) => {
dispatch(setGameDeleting(gameId));
try {
await window.electron.deleteGameFolder(gameId);
await window.electron.removeGame(gameId);
updateLibrary();
} finally {
dispatch(removeGameFromDeleting(gameId));
}
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
dispatch(clearDownload());
updateLibrary();
removeGameInstaller(gameId);
};
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
updateLibrary();

View File

@ -29,5 +29,17 @@ export function useToast() {
[dispatch]
);
return { showSuccessToast, showErrorToast };
const showWarningToast = useCallback(
(message: string) => {
dispatch(
showToast({
message,
type: "warning",
})
);
},
[dispatch]
);
return { showSuccessToast, showErrorToast, showWarningToast };
}

Some files were not shown because too many files have changed in this diff Show More