mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	feat: migrating to electron-vite
This commit is contained in:
		
						commit
						1db5a9c295
					
				
					 183 changed files with 18535 additions and 0 deletions
				
			
		
							
								
								
									
										9
									
								
								.editorconfig
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.editorconfig
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
end_of_line = lf
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
trim_trailing_whitespace = true
 | 
			
		||||
							
								
								
									
										5
									
								
								.env.example
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.env.example
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
 | 
			
		||||
MAIN_VITE_ONLINEFIX_USERNAME=YOUR_USERNAME
 | 
			
		||||
MAIN_VITE_ONLINEFIX_PASSWORD=YOUR_PASSWORD
 | 
			
		||||
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
 | 
			
		||||
RENDERER_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
 | 
			
		||||
							
								
								
									
										4
									
								
								.eslintignore
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.eslintignore
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
node_modules
 | 
			
		||||
dist
 | 
			
		||||
out
 | 
			
		||||
.gitignore
 | 
			
		||||
							
								
								
									
										9
									
								
								.eslintrc.cjs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.eslintrc.cjs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
module.exports = {
 | 
			
		||||
  extends: [
 | 
			
		||||
    "eslint:recommended",
 | 
			
		||||
    "plugin:react/recommended",
 | 
			
		||||
    "plugin:react/jsx-runtime",
 | 
			
		||||
    "@electron-toolkit/eslint-config-ts/recommended",
 | 
			
		||||
    "@electron-toolkit/eslint-config-prettier",
 | 
			
		||||
  ],
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
node_modules
 | 
			
		||||
dist
 | 
			
		||||
out
 | 
			
		||||
.DS_Store
 | 
			
		||||
*.log*
 | 
			
		||||
.env
 | 
			
		||||
							
								
								
									
										3
									
								
								.npmrc
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.npmrc
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
electron_mirror=https://npmmirror.com/mirrors/electron/
 | 
			
		||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
 | 
			
		||||
shamefully-hoist=true
 | 
			
		||||
							
								
								
									
										6
									
								
								.prettierignore
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.prettierignore
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
out
 | 
			
		||||
dist
 | 
			
		||||
pnpm-lock.yaml
 | 
			
		||||
LICENSE.md
 | 
			
		||||
tsconfig.json
 | 
			
		||||
tsconfig.*.json
 | 
			
		||||
							
								
								
									
										4
									
								
								.prettierrc.yaml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.prettierrc.yaml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
singleQuote: false
 | 
			
		||||
semi: true
 | 
			
		||||
tabWidth: 2
 | 
			
		||||
trailingComma: es5
 | 
			
		||||
							
								
								
									
										3
									
								
								.vscode/extensions.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.vscode/extensions.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
{
 | 
			
		||||
  "recommendations": ["dbaeumer.vscode-eslint"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								.vscode/launch.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								.vscode/launch.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
{
 | 
			
		||||
  "version": "0.2.0",
 | 
			
		||||
  "configurations": [
 | 
			
		||||
    {
 | 
			
		||||
      "name": "Debug Main Process",
 | 
			
		||||
      "type": "node",
 | 
			
		||||
      "request": "launch",
 | 
			
		||||
      "cwd": "${workspaceRoot}",
 | 
			
		||||
      "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
 | 
			
		||||
      "windows": {
 | 
			
		||||
        "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
 | 
			
		||||
      },
 | 
			
		||||
      "runtimeArgs": ["--sourcemap"],
 | 
			
		||||
      "env": {
 | 
			
		||||
        "REMOTE_DEBUGGING_PORT": "9222"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "name": "Debug Renderer Process",
 | 
			
		||||
      "port": 9222,
 | 
			
		||||
      "request": "attach",
 | 
			
		||||
      "type": "chrome",
 | 
			
		||||
      "webRoot": "${workspaceFolder}/src/renderer",
 | 
			
		||||
      "timeout": 60000,
 | 
			
		||||
      "presentation": {
 | 
			
		||||
        "hidden": true
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "compounds": [
 | 
			
		||||
    {
 | 
			
		||||
      "name": "Debug All",
 | 
			
		||||
      "configurations": ["Debug Main Process", "Debug Renderer Process"],
 | 
			
		||||
      "presentation": {
 | 
			
		||||
        "order": 1
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								.vscode/settings.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.vscode/settings.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
{
 | 
			
		||||
  "[typescript]": {
 | 
			
		||||
    "editor.defaultFormatter": "esbenp.prettier-vscode"
 | 
			
		||||
  },
 | 
			
		||||
  "[javascript]": {
 | 
			
		||||
    "editor.defaultFormatter": "esbenp.prettier-vscode"
 | 
			
		||||
  },
 | 
			
		||||
  "[json]": {
 | 
			
		||||
    "editor.defaultFormatter": "esbenp.prettier-vscode"
 | 
			
		||||
  },
 | 
			
		||||
  "i18n-ally.localesPaths": ["src/locales"],
 | 
			
		||||
  "editor.defaultFormatter": "esbenp.prettier-vscode",
 | 
			
		||||
  "editor.formatOnSave": true
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										92
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,92 @@
 | 
			
		|||
# Hydra
 | 
			
		||||
 | 
			
		||||
<a href="https://discord.gg/hydralauncher" target="_blank"></a>
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.
 | 
			
		||||
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/).
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
### Install Node.js
 | 
			
		||||
 | 
			
		||||
Ensure you have Node.js installed on your machine. If not, download and install it from [nodejs.org](https://nodejs.org/).
 | 
			
		||||
 | 
			
		||||
### Install Yarn
 | 
			
		||||
 | 
			
		||||
Yarn is a package manager for Node.js. If you haven't installed Yarn yet, you can do so by following the instructions on [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
 | 
			
		||||
 | 
			
		||||
### Clone the Repository
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
git clone https://github.com/hydralauncher/hydra.git
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Install Node Dependencies
 | 
			
		||||
 | 
			
		||||
Navigate to the project directory and install the Node dependencies using Yarn:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
cd hydra
 | 
			
		||||
yarn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Install Python 3.9
 | 
			
		||||
 | 
			
		||||
Ensure you have Python installed on your machine. You can download and install it from [python.org](https://www.python.org/downloads/release/python-3919/).
 | 
			
		||||
 | 
			
		||||
### Install Python Dependencies
 | 
			
		||||
 | 
			
		||||
Install the required Python dependencies using pip:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
pip install -r requirements.txt
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Environment variables
 | 
			
		||||
 | 
			
		||||
You'll need an SteamGridDB API Key in order to fetch the game icons on installation.
 | 
			
		||||
If you want to have onlinefix as a repacker you'll need to add your credentials to the .env
 | 
			
		||||
 | 
			
		||||
Once you have it, you can paste the `.env.example` file and put it on `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
 | 
			
		||||
 | 
			
		||||
## Running
 | 
			
		||||
 | 
			
		||||
Once you've got all things set up, you can run the following command to start both the Electron process and the bittorrent client:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn start
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Build
 | 
			
		||||
 | 
			
		||||
### Build the bittorrent client
 | 
			
		||||
 | 
			
		||||
Build the bittorrent client by using this command:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
python torrent-client/setup.py build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Build the Electron application
 | 
			
		||||
 | 
			
		||||
Build the Electron application by using this command:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn make
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Contributors
 | 
			
		||||
 | 
			
		||||
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
 | 
			
		||||
  <img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
 | 
			
		||||
</a>
 | 
			
		||||
 | 
			
		||||
Made with [contrib.rocks](https://contrib.rocks).
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
Hydra is licensed under the [MIT License](LICENSE).
 | 
			
		||||
							
								
								
									
										12
									
								
								build/entitlements.mac.plist
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								build/entitlements.mac.plist
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 | 
			
		||||
<plist version="1.0">
 | 
			
		||||
  <dict>
 | 
			
		||||
    <key>com.apple.security.cs.allow-jit</key>
 | 
			
		||||
    <true/>
 | 
			
		||||
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
 | 
			
		||||
    <true/>
 | 
			
		||||
    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
 | 
			
		||||
    <true/>
 | 
			
		||||
  </dict>
 | 
			
		||||
</plist>
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								build/icon.icns
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								build/icon.icns
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								build/icon.ico
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								build/icon.ico
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 121 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								build/icon.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								build/icon.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 35 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								docs/screenshot.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/screenshot.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 390 KiB  | 
							
								
								
									
										45
									
								
								electron-builder.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								electron-builder.yml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
appId: site.hydralauncher.hydra
 | 
			
		||||
productName: Hydra
 | 
			
		||||
directories:
 | 
			
		||||
  buildResources: build
 | 
			
		||||
files:
 | 
			
		||||
  - "!**/.vscode/*"
 | 
			
		||||
  - "!src/*"
 | 
			
		||||
  - "!electron.vite.config.{js,ts,mjs,cjs}"
 | 
			
		||||
  - "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
 | 
			
		||||
  - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
 | 
			
		||||
  - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
 | 
			
		||||
asarUnpack:
 | 
			
		||||
  - resources/**
 | 
			
		||||
win:
 | 
			
		||||
  executableName: Hydra
 | 
			
		||||
nsis:
 | 
			
		||||
  artifactName: ${name}-${version}-setup.${ext}
 | 
			
		||||
  shortcutName: ${productName}
 | 
			
		||||
  uninstallDisplayName: ${productName}
 | 
			
		||||
  createDesktopShortcut: always
 | 
			
		||||
mac:
 | 
			
		||||
  entitlementsInherit: build/entitlements.mac.plist
 | 
			
		||||
  extendInfo:
 | 
			
		||||
    - NSCameraUsageDescription: Application requests access to the device's camera.
 | 
			
		||||
    - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
 | 
			
		||||
    - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
 | 
			
		||||
    - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
 | 
			
		||||
  notarize: false
 | 
			
		||||
dmg:
 | 
			
		||||
  artifactName: ${name}-${version}.${ext}
 | 
			
		||||
linux:
 | 
			
		||||
  target:
 | 
			
		||||
    - AppImage
 | 
			
		||||
    - snap
 | 
			
		||||
    - deb
 | 
			
		||||
  maintainer: electronjs.org
 | 
			
		||||
  category: Utility
 | 
			
		||||
appImage:
 | 
			
		||||
  artifactName: ${name}-${version}.${ext}
 | 
			
		||||
npmRebuild: false
 | 
			
		||||
publish:
 | 
			
		||||
  provider: generic
 | 
			
		||||
  url: https://example.com/auto-updates
 | 
			
		||||
electronDownload:
 | 
			
		||||
  mirror: https://npmmirror.com/mirrors/electron/
 | 
			
		||||
							
								
								
									
										43
									
								
								electron.vite.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								electron.vite.config.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
import { resolve } from "path";
 | 
			
		||||
import {
 | 
			
		||||
  defineConfig,
 | 
			
		||||
  loadEnv,
 | 
			
		||||
  swcPlugin,
 | 
			
		||||
  externalizeDepsPlugin,
 | 
			
		||||
} from "electron-vite";
 | 
			
		||||
import react from "@vitejs/plugin-react";
 | 
			
		||||
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
 | 
			
		||||
import svgr from "vite-plugin-svgr";
 | 
			
		||||
 | 
			
		||||
export default defineConfig(({ command, mode }) => {
 | 
			
		||||
  loadEnv(mode);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    main: {
 | 
			
		||||
      build: {
 | 
			
		||||
        rollupOptions: {
 | 
			
		||||
          external: ["better-sqlite3"],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      resolve: {
 | 
			
		||||
        alias: {
 | 
			
		||||
          "@main": resolve("src/main"),
 | 
			
		||||
          "@locales": resolve("src/locales"),
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      plugins: [externalizeDepsPlugin(), swcPlugin()],
 | 
			
		||||
    },
 | 
			
		||||
    preload: {
 | 
			
		||||
      plugins: [externalizeDepsPlugin()],
 | 
			
		||||
    },
 | 
			
		||||
    renderer: {
 | 
			
		||||
      resolve: {
 | 
			
		||||
        alias: {
 | 
			
		||||
          "@renderer": resolve("src/renderer/src"),
 | 
			
		||||
          "@locales": resolve("src/locales"),
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      plugins: [svgr(), react(), vanillaExtractPlugin()],
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										81
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "hydra",
 | 
			
		||||
  "version": "1.0.0",
 | 
			
		||||
  "description": "An Electron application with React and TypeScript",
 | 
			
		||||
  "main": "./out/main/index.js",
 | 
			
		||||
  "author": "example.com",
 | 
			
		||||
  "homepage": "https://electron-vite.org",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "format": "prettier --write .",
 | 
			
		||||
    "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
 | 
			
		||||
    "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
 | 
			
		||||
    "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
 | 
			
		||||
    "typecheck": "npm run typecheck:node && npm run typecheck:web",
 | 
			
		||||
    "start": "electron-vite preview",
 | 
			
		||||
    "dev": "electron-vite dev",
 | 
			
		||||
    "build": "npm run typecheck && electron-vite build",
 | 
			
		||||
    "postinstall": "electron-builder install-app-deps",
 | 
			
		||||
    "build:unpack": "npm run build && electron-builder --dir",
 | 
			
		||||
    "build:win": "npm run build && electron-builder --win",
 | 
			
		||||
    "build:mac": "electron-vite build && electron-builder --mac",
 | 
			
		||||
    "build:linux": "electron-vite build && electron-builder --linux"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@electron-toolkit/preload": "^3.0.0",
 | 
			
		||||
    "@electron-toolkit/utils": "^3.0.0",
 | 
			
		||||
    "@fontsource/fira-mono": "^5.0.13",
 | 
			
		||||
    "@fontsource/fira-sans": "^5.0.20",
 | 
			
		||||
    "@primer/octicons-react": "^19.9.0",
 | 
			
		||||
    "@reduxjs/toolkit": "^2.2.3",
 | 
			
		||||
    "@sentry/electron": "^4.23.0",
 | 
			
		||||
    "@sentry/react": "^7.111.0",
 | 
			
		||||
    "@vanilla-extract/css": "^1.14.2",
 | 
			
		||||
    "@vanilla-extract/recipes": "^0.5.2",
 | 
			
		||||
    "axios": "^1.6.8",
 | 
			
		||||
    "better-sqlite3": "^9.5.0",
 | 
			
		||||
    "check-disk-space": "^3.4.0",
 | 
			
		||||
    "classnames": "^2.5.1",
 | 
			
		||||
    "color.js": "^1.2.0",
 | 
			
		||||
    "date-fns": "^3.6.0",
 | 
			
		||||
    "flexsearch": "^0.7.43",
 | 
			
		||||
    "i18next": "^23.11.2",
 | 
			
		||||
    "i18next-browser-languagedetector": "^7.2.1",
 | 
			
		||||
    "jsdom": "^24.0.0",
 | 
			
		||||
    "lodash-es": "^4.17.21",
 | 
			
		||||
    "lottie-react": "^2.4.0",
 | 
			
		||||
    "parse-torrent": "^11.0.16",
 | 
			
		||||
    "ps-list": "^8.1.1",
 | 
			
		||||
    "react-i18next": "^14.1.0",
 | 
			
		||||
    "react-loading-skeleton": "^3.4.0",
 | 
			
		||||
    "react-redux": "^9.1.1",
 | 
			
		||||
    "react-router-dom": "^6.22.3",
 | 
			
		||||
    "tasklist": "^5.0.0",
 | 
			
		||||
    "typeorm": "^0.3.20",
 | 
			
		||||
    "winston": "^3.13.0",
 | 
			
		||||
    "yaml": "^2.4.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@electron-toolkit/eslint-config-prettier": "^2.0.0",
 | 
			
		||||
    "@electron-toolkit/eslint-config-ts": "^1.0.1",
 | 
			
		||||
    "@electron-toolkit/tsconfig": "^1.0.1",
 | 
			
		||||
    "@swc/core": "^1.4.16",
 | 
			
		||||
    "@types/lodash-es": "^4.17.12",
 | 
			
		||||
    "@types/node": "^18.19.9",
 | 
			
		||||
    "@types/react": "^18.2.48",
 | 
			
		||||
    "@types/react-dom": "^18.2.18",
 | 
			
		||||
    "@vanilla-extract/vite-plugin": "^4.0.7",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.2.1",
 | 
			
		||||
    "electron": "^28.2.0",
 | 
			
		||||
    "electron-builder": "^24.9.1",
 | 
			
		||||
    "electron-vite": "^2.0.0",
 | 
			
		||||
    "eslint": "^8.56.0",
 | 
			
		||||
    "eslint-plugin-react": "^7.33.2",
 | 
			
		||||
    "prettier": "^3.2.4",
 | 
			
		||||
    "react": "^18.2.0",
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "typescript": "^5.3.3",
 | 
			
		||||
    "vite": "^5.0.12",
 | 
			
		||||
    "vite-plugin-svgr": "^4.2.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
libtorrent
 | 
			
		||||
cx_Freeze
 | 
			
		||||
cx_Logging; sys_platform == 'win32'
 | 
			
		||||
lief; sys_platform == 'win32'
 | 
			
		||||
pywin32; sys_platform == 'win32'
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								resources/icon.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								resources/icon.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 35 KiB  | 
							
								
								
									
										141
									
								
								src/locales/en/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/locales/en/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
{
 | 
			
		||||
  "home": {
 | 
			
		||||
    "featured": "Featured",
 | 
			
		||||
    "recently_added": "Recently added",
 | 
			
		||||
    "trending": "Trending",
 | 
			
		||||
    "surprise_me": "Surprise me",
 | 
			
		||||
    "no_results": "No results found"
 | 
			
		||||
  },
 | 
			
		||||
  "sidebar": {
 | 
			
		||||
    "catalogue": "Catalogue",
 | 
			
		||||
    "downloads": "Downloads",
 | 
			
		||||
    "settings": "Settings",
 | 
			
		||||
    "my_library": "My library",
 | 
			
		||||
    "downloading_metadata": "{{title}} (Downloading metadata…)",
 | 
			
		||||
    "checking_files": "{{title}} ({{percentage}} - Checking files…)",
 | 
			
		||||
    "paused": "{{title}} (Paused)",
 | 
			
		||||
    "downloading": "{{title}} ({{percentage}} - Downloading…)",
 | 
			
		||||
    "filter": "Filter library",
 | 
			
		||||
    "follow_us": "Follow us",
 | 
			
		||||
    "home": "Home"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Search",
 | 
			
		||||
    "home": "Home",
 | 
			
		||||
    "catalogue": "Catalogue",
 | 
			
		||||
    "downloads": "Downloads",
 | 
			
		||||
    "search_results": "Search results",
 | 
			
		||||
    "settings": "Settings"
 | 
			
		||||
  },
 | 
			
		||||
  "bottom_panel": {
 | 
			
		||||
    "no_downloads_in_progress": "No downloads in progress",
 | 
			
		||||
    "downloading_metadata": "Downloading {{title}} metadata…",
 | 
			
		||||
    "checking_files": "Checking {{title}} files… ({{percentage}} complete)",
 | 
			
		||||
    "downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}"
 | 
			
		||||
  },
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "next_page": "Next page",
 | 
			
		||||
    "previous_page": "Previous page"
 | 
			
		||||
  },
 | 
			
		||||
  "game_details": {
 | 
			
		||||
    "open_download_options": "Open download options",
 | 
			
		||||
    "download_options_zero": "No download option",
 | 
			
		||||
    "download_options_one": "{{count}} download option",
 | 
			
		||||
    "download_options_other": "{{count}} download options",
 | 
			
		||||
    "updated_at": "Updated {{updated_at}}",
 | 
			
		||||
    "install": "Install",
 | 
			
		||||
    "resume": "Resume",
 | 
			
		||||
    "pause": "Pause",
 | 
			
		||||
    "cancel": "Cancel",
 | 
			
		||||
    "remove": "Remove",
 | 
			
		||||
    "remove_from_list": "Remove",
 | 
			
		||||
    "space_left_on_disk": "{{space}} left on disk",
 | 
			
		||||
    "eta": "Conclusion {{eta}}",
 | 
			
		||||
    "downloading_metadata": "Downloading metadata…",
 | 
			
		||||
    "checking_files": "Checking files…",
 | 
			
		||||
    "filter": "Filter repacks",
 | 
			
		||||
    "requirements": "System requirements",
 | 
			
		||||
    "minimum": "Minimum",
 | 
			
		||||
    "recommended": "Recommended",
 | 
			
		||||
    "no_minimum_requirements": "{{title}} doesn't provide minimum requirements information",
 | 
			
		||||
    "no_recommended_requirements": "{{title}} doesn't provide recommended requirements information",
 | 
			
		||||
    "paused_progress": "{{progress}} (Paused)",
 | 
			
		||||
    "release_date": "Released in {{date}}",
 | 
			
		||||
    "publisher": "Published by {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copy link",
 | 
			
		||||
    "copied_link_to_clipboard": "Link copied",
 | 
			
		||||
    "hours": "hours",
 | 
			
		||||
    "minutes": "minutes",
 | 
			
		||||
    "accuracy": "{{accuracy}}% accuracy",
 | 
			
		||||
    "add_to_library": "Add to library",
 | 
			
		||||
    "remove_from_library": "Remove from library",
 | 
			
		||||
    "no_downloads": "No downloads available",
 | 
			
		||||
    "play_time": "Played for {{amount}}",
 | 
			
		||||
    "last_time_played": "Last played {{period}}",
 | 
			
		||||
    "not_played_yet": "You haven't played {{title}} yet",
 | 
			
		||||
    "next_suggestion": "Next suggestion",
 | 
			
		||||
    "play": "Play",
 | 
			
		||||
    "deleting": "Deleting installer…",
 | 
			
		||||
    "close": "Close",
 | 
			
		||||
    "playing_now": "Playing now"
 | 
			
		||||
  },
 | 
			
		||||
  "activation": {
 | 
			
		||||
    "title": "Activate Hydra",
 | 
			
		||||
    "installation_id": "Installation ID:",
 | 
			
		||||
    "enter_activation_code": "Enter your activation code",
 | 
			
		||||
    "message": "If you don't know where to ask for this, then you shouldn't have this.",
 | 
			
		||||
    "activate": "Activate",
 | 
			
		||||
    "loading": "Loading…"
 | 
			
		||||
  },
 | 
			
		||||
  "downloads": {
 | 
			
		||||
    "resume": "Resume",
 | 
			
		||||
    "pause": "Pause",
 | 
			
		||||
    "eta": "Conclusion {{eta}}",
 | 
			
		||||
    "paused": "Paused",
 | 
			
		||||
    "verifying": "Verifying…",
 | 
			
		||||
    "completed_at": "Completed in {{date}}",
 | 
			
		||||
    "completed": "Completed",
 | 
			
		||||
    "cancelled": "Cancelled",
 | 
			
		||||
    "download_again": "Download again",
 | 
			
		||||
    "cancel": "Cancel",
 | 
			
		||||
    "filter": "Filter downloaded games",
 | 
			
		||||
    "remove": "Remove",
 | 
			
		||||
    "downloading_metadata": "Downloading metadata…",
 | 
			
		||||
    "checking_files": "Checking files…",
 | 
			
		||||
    "starting_download": "Starting download…",
 | 
			
		||||
    "deleting": "Deleting installer…",
 | 
			
		||||
    "delete": "Remove installer",
 | 
			
		||||
    "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"
 | 
			
		||||
  },
 | 
			
		||||
  "settings": {
 | 
			
		||||
    "downloads_path": "Downloads path",
 | 
			
		||||
    "change": "Update",
 | 
			
		||||
    "notifications": "Notifications",
 | 
			
		||||
    "enable_download_notifications": "When a download is complete",
 | 
			
		||||
    "enable_repack_list_notifications": "When a new repack is added",
 | 
			
		||||
    "telemetry": "Telemetry",
 | 
			
		||||
    "telemetry_description": "Enable anonymous usage statistics"
 | 
			
		||||
  },
 | 
			
		||||
  "notifications": {
 | 
			
		||||
    "download_complete": "Download complete",
 | 
			
		||||
    "game_ready_to_install": "{{title}} is ready to install",
 | 
			
		||||
    "repack_list_updated": "Repack list updated",
 | 
			
		||||
    "repack_count_one": "{{count}} repack added",
 | 
			
		||||
    "repack_count_other": "{{count}} repacks added"
 | 
			
		||||
  },
 | 
			
		||||
  "system_tray": {
 | 
			
		||||
    "open": "Open Hydra",
 | 
			
		||||
    "quit": "Quit"
 | 
			
		||||
  },
 | 
			
		||||
  "game_card": {
 | 
			
		||||
    "no_downloads": "No downloads available"
 | 
			
		||||
  },
 | 
			
		||||
  "binary_not_found_modal": {
 | 
			
		||||
    "title": "Programs not installed",
 | 
			
		||||
    "description": "Wine or Lutris executables were not found on your system",
 | 
			
		||||
    "instructions": "Check the correct way to install any of them on your Linux distro so that the game can run normally"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										141
									
								
								src/locales/es/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/locales/es/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
{
 | 
			
		||||
  "home": {
 | 
			
		||||
    "featured": "Destacado",
 | 
			
		||||
    "recently_added": "Recién Añadidos",
 | 
			
		||||
    "trending": "Tendencias",
 | 
			
		||||
    "surprise_me": "¡Sorpréndeme!",
 | 
			
		||||
    "no_results": "No se encontraron resultados"
 | 
			
		||||
  },
 | 
			
		||||
  "sidebar": {
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Descargas",
 | 
			
		||||
    "settings": "Ajustes",
 | 
			
		||||
    "my_library": "Mi biblioteca",
 | 
			
		||||
    "downloading_metadata": "{{title}} (Descargando metadatos…)",
 | 
			
		||||
    "checking_files": "{{title}} ({{percentage}} - Analizando archivos…)",
 | 
			
		||||
    "paused": "{{title}} (Pausado)",
 | 
			
		||||
    "downloading": "{{title}} ({{percentage}} - Descargando…)",
 | 
			
		||||
    "filter": "Filtrar biblioteca",
 | 
			
		||||
    "home": "Hogar",
 | 
			
		||||
    "follow_us": "Síganos"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Buscar",
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Descargas",
 | 
			
		||||
    "search_results": "Resultados de búsqueda",
 | 
			
		||||
    "settings": "Ajustes",
 | 
			
		||||
    "home": "Início"
 | 
			
		||||
  },
 | 
			
		||||
  "bottom_panel": {
 | 
			
		||||
    "no_downloads_in_progress": "Sin descargas en progreso",
 | 
			
		||||
    "downloading_metadata": "Descargando metadatos de {{title}}…",
 | 
			
		||||
    "checking_files": "Analizando archivos de {{title}} - ({{percentage}} completado)",
 | 
			
		||||
    "downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}"
 | 
			
		||||
  },
 | 
			
		||||
  "game_details": {
 | 
			
		||||
    "open_download_options": "Ver opciones de descargas",
 | 
			
		||||
    "download_options_zero": "No hay opciones de descargas disponibles",
 | 
			
		||||
    "download_options_one": "{{count}} opción de descarga",
 | 
			
		||||
    "download_options_other": "{{count}} opciones de descargas",
 | 
			
		||||
    "updated_at": "Actualizado el {{updated_at}}",
 | 
			
		||||
    "resume": "Continuar",
 | 
			
		||||
    "pause": "Pausa",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "remove": "Eliminar",
 | 
			
		||||
    "remove_from_list": "Quitar",
 | 
			
		||||
    "space_left_on_disk": "{{space}} restantes en el disco",
 | 
			
		||||
    "eta": "Finalizando {{eta}}",
 | 
			
		||||
    "downloading_metadata": "Descargando metadatos…",
 | 
			
		||||
    "checking_files": "Analizando archivos…",
 | 
			
		||||
    "filter": "Filtrar repacks",
 | 
			
		||||
    "requirements": "Requisitos del Sistema",
 | 
			
		||||
    "minimum": "Mínimos",
 | 
			
		||||
    "recommended": "Recomendados",
 | 
			
		||||
    "no_minimum_requirements": "Sin requisitos mínimos para {{title}}",
 | 
			
		||||
    "no_recommended_requirements": "{{title}} no tiene requisitos recomendados",
 | 
			
		||||
    "paused_progress": "{{progress}} (Pausado)",
 | 
			
		||||
    "release_date": "Fecha de lanzamiento {{date}}",
 | 
			
		||||
    "publisher": "Publicado por {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copiar enlace",
 | 
			
		||||
    "copied_link_to_clipboard": "Enlace copiado",
 | 
			
		||||
    "hours": "horas",
 | 
			
		||||
    "minutes": "minutos",
 | 
			
		||||
    "accuracy": "{{accuracy}}% precisión",
 | 
			
		||||
    "add_to_library": "Agregar a la biblioteca",
 | 
			
		||||
    "remove_from_library": "Eliminar de la biblioteca",
 | 
			
		||||
    "no_downloads": "No hay descargas disponibles",
 | 
			
		||||
    "next_suggestion": "Siguiente sugerencia",
 | 
			
		||||
    "play_time": "Jugado por {{cantidad}}",
 | 
			
		||||
    "install": "Instalar",
 | 
			
		||||
    "play": "Jugar",
 | 
			
		||||
    "not_played_yet": "Aún no has jugado a {{title}}",
 | 
			
		||||
    "close": "Cerca",
 | 
			
		||||
    "deleting": "Eliminando instalador…",
 | 
			
		||||
    "playing_now": "Jugando ahora",
 | 
			
		||||
    "last_time_played": "Jugado por última vez {{period}}"
 | 
			
		||||
  },
 | 
			
		||||
  "activation": {
 | 
			
		||||
    "title": "Activar Hydra",
 | 
			
		||||
    "installation_id": "ID de la Instalación:",
 | 
			
		||||
    "enter_activation_code": "Introduce tu código de activación",
 | 
			
		||||
    "message": "Si no sabes donde obtener el código, no deberías de tener esto.",
 | 
			
		||||
    "activate": "Activar",
 | 
			
		||||
    "loading": "Cargando…"
 | 
			
		||||
  },
 | 
			
		||||
  "downloads": {
 | 
			
		||||
    "resume": "Resumir",
 | 
			
		||||
    "pause": "Pausa",
 | 
			
		||||
    "eta": "Finalizando {{eta}}",
 | 
			
		||||
    "paused": "En Pausa",
 | 
			
		||||
    "verifying": "Verificando…",
 | 
			
		||||
    "completed_at": "Completado el {{date}}",
 | 
			
		||||
    "completed": "Completado",
 | 
			
		||||
    "cancelled": "Cancelado",
 | 
			
		||||
    "download_again": "Descargar de nuevo",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "filter": "Buscar juegos descargados",
 | 
			
		||||
    "remove": "Eliminar",
 | 
			
		||||
    "downloading_metadata": "Descargando metadatos…",
 | 
			
		||||
    "checking_files": "Verificando archivos…",
 | 
			
		||||
    "starting_download": "Iniciando descarga…",
 | 
			
		||||
    "remove_from_list": "Eliminar",
 | 
			
		||||
    "delete": "Quitar instalador",
 | 
			
		||||
    "delete_modal_description": "Esto eliminará todos los archivos de instalación de su computadora.",
 | 
			
		||||
    "delete_modal_title": "¿Está seguro?",
 | 
			
		||||
    "deleting": "Eliminando instalador…",
 | 
			
		||||
    "install": "Instalar"
 | 
			
		||||
  },
 | 
			
		||||
  "settings": {
 | 
			
		||||
    "downloads_path": "Ruta de descarga",
 | 
			
		||||
    "change": "Cambiar",
 | 
			
		||||
    "notifications": "Notificaciones",
 | 
			
		||||
    "enable_download_notifications": "Cuando se completa una descarga",
 | 
			
		||||
    "enable_repack_list_notifications": "Cuando se añade un repack nuevo",
 | 
			
		||||
    "telemetry": "Telemetria",
 | 
			
		||||
    "telemetry_description": "Habilitar estadísticas de uso anónimas"
 | 
			
		||||
  },
 | 
			
		||||
  "notifications": {
 | 
			
		||||
    "download_complete": "Descarga completada",
 | 
			
		||||
    "game_ready_to_install": "{{title}} está listo para instalarse",
 | 
			
		||||
    "repack_list_updated": "Lista de repacks actualizadas",
 | 
			
		||||
    "repack_count_one": "{{count}} repack ha sido añadido",
 | 
			
		||||
    "repack_count_other": "{{count}} repacks añadidos"
 | 
			
		||||
  },
 | 
			
		||||
  "system_tray": {
 | 
			
		||||
    "open": "Abrir Hydra",
 | 
			
		||||
    "quit": "Salir"
 | 
			
		||||
  },
 | 
			
		||||
  "game_card": {
 | 
			
		||||
    "no_downloads": "No hay descargas disponibles"
 | 
			
		||||
  },
 | 
			
		||||
  "binary_not_found_modal": {
 | 
			
		||||
    "title": "Programas no instalados",
 | 
			
		||||
    "description": "Los ejecutables de Wine o Lutris no se encontraron en su sistema",
 | 
			
		||||
    "instructions": "Comprueba la forma correcta de instalar cualquiera de ellos en tu distro Linux para que el juego pueda ejecutarse con normalidad"
 | 
			
		||||
  },
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "next_page": "Siguiente página",
 | 
			
		||||
    "previous_page": "Pagina anterior"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										141
									
								
								src/locales/fr/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/locales/fr/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
{
 | 
			
		||||
  "home": {
 | 
			
		||||
    "featured": "En vedette",
 | 
			
		||||
    "recently_added": "Récemment ajouté",
 | 
			
		||||
    "trending": "Tendance",
 | 
			
		||||
    "surprise_me": "Surprenez-moi",
 | 
			
		||||
    "no_results": "Aucun résultat trouvé"
 | 
			
		||||
  },
 | 
			
		||||
  "sidebar": {
 | 
			
		||||
    "catalogue": "Catalogue",
 | 
			
		||||
    "downloads": "Téléchargements",
 | 
			
		||||
    "settings": "Paramètres",
 | 
			
		||||
    "my_library": "Ma bibliothèque",
 | 
			
		||||
    "downloading_metadata": "{{title}} (Téléchargement des métadonnées…)",
 | 
			
		||||
    "checking_files": "{{title}} ({{percentage}} - Vérification des fichiers…)",
 | 
			
		||||
    "paused": "{{title}} (En pause)",
 | 
			
		||||
    "downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
 | 
			
		||||
    "filter": "Filtrer la bibliothèque",
 | 
			
		||||
    "home": "Page d’accueil",
 | 
			
		||||
    "follow_us": "Suivez-nous"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Recherche",
 | 
			
		||||
    "catalogue": "Catalogue",
 | 
			
		||||
    "downloads": "Téléchargements",
 | 
			
		||||
    "search_results": "Résultats de la recherche",
 | 
			
		||||
    "settings": "Paramètres",
 | 
			
		||||
    "home": "Maison"
 | 
			
		||||
  },
 | 
			
		||||
  "bottom_panel": {
 | 
			
		||||
    "no_downloads_in_progress": "Aucun téléchargement en cours",
 | 
			
		||||
    "downloading_metadata": "Téléchargement des métadonnées de {{title}}…",
 | 
			
		||||
    "checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} complet)",
 | 
			
		||||
    "downloading": "Téléchargement de {{title}}… ({{percentage}} complet) - Conclusion dans {{eta}} - {{speed}}"
 | 
			
		||||
  },
 | 
			
		||||
  "game_details": {
 | 
			
		||||
    "open_download_options": "Ouvrir les options de téléchargement",
 | 
			
		||||
    "download_options_zero": "Aucune option de téléchargement",
 | 
			
		||||
    "download_options_one": "{{count}} option de téléchargement",
 | 
			
		||||
    "download_options_other": "{{count}} options de téléchargement",
 | 
			
		||||
    "updated_at": "Mis à jour le {{updated_at}}",
 | 
			
		||||
    "resume": "Reprendre",
 | 
			
		||||
    "pause": "Pause",
 | 
			
		||||
    "cancel": "Annuler",
 | 
			
		||||
    "remove": "Supprimer",
 | 
			
		||||
    "remove_from_list": "Retirer",
 | 
			
		||||
    "space_left_on_disk": "{{space}} restant sur le disque",
 | 
			
		||||
    "eta": "Conclusion dans {{eta}}",
 | 
			
		||||
    "downloading_metadata": "Téléchargement des métadonnées en cours…",
 | 
			
		||||
    "checking_files": "Vérification des fichiers…",
 | 
			
		||||
    "filter": "Filtrer les réductions",
 | 
			
		||||
    "requirements": "Configuration requise",
 | 
			
		||||
    "minimum": "Minimum",
 | 
			
		||||
    "recommended": "Recommandée",
 | 
			
		||||
    "no_minimum_requirements": "{{title}} ne fournit pas d'informations sur les exigences minimales",
 | 
			
		||||
    "no_recommended_requirements": "{{title}} ne fournit pas d'informations sur les exigences recommandées",
 | 
			
		||||
    "paused_progress": "{{progress}} (En pause)",
 | 
			
		||||
    "release_date": "Sorti le {{date}}",
 | 
			
		||||
    "publisher": "Édité par {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copier le lien",
 | 
			
		||||
    "copied_link_to_clipboard": "Lien copié",
 | 
			
		||||
    "hours": "heures",
 | 
			
		||||
    "minutes": "minutes",
 | 
			
		||||
    "accuracy": "{{accuracy}}% précision",
 | 
			
		||||
    "add_to_library": "Ajouter à la bibliothèque",
 | 
			
		||||
    "remove_from_library": "Supprimer de la bibliothèque",
 | 
			
		||||
    "no_downloads": "Aucun téléchargement disponible",
 | 
			
		||||
    "next_suggestion": "Suggestion suivante",
 | 
			
		||||
    "play_time": "Joué pour {{montant}}",
 | 
			
		||||
    "install": "Installer",
 | 
			
		||||
    "play": "Jouer",
 | 
			
		||||
    "not_played_yet": "Vous n'avez pas encore joué à {{title}}",
 | 
			
		||||
    "close": "Fermer",
 | 
			
		||||
    "deleting": "Suppression du programme d'installation…",
 | 
			
		||||
    "playing_now": "Je joue maintenant",
 | 
			
		||||
    "last_time_played": "Dernière lecture {{période}}"
 | 
			
		||||
  },
 | 
			
		||||
  "activation": {
 | 
			
		||||
    "title": "Activer Hydra",
 | 
			
		||||
    "installation_id": "ID d'installation :",
 | 
			
		||||
    "enter_activation_code": "Entrez votre code d'activation",
 | 
			
		||||
    "message": "Si vous ne savez pas où demander cela, vous ne devriez pas l'avoir.",
 | 
			
		||||
    "activate": "Activer",
 | 
			
		||||
    "loading": "Chargement en cours…"
 | 
			
		||||
  },
 | 
			
		||||
  "downloads": {
 | 
			
		||||
    "resume": "Reprendre",
 | 
			
		||||
    "pause": "Pause",
 | 
			
		||||
    "eta": "Conclusion dans {{eta}}",
 | 
			
		||||
    "paused": "En pause",
 | 
			
		||||
    "verifying": "Vérification en cours…",
 | 
			
		||||
    "completed_at": "Terminé en {{date}}",
 | 
			
		||||
    "completed": "Terminé",
 | 
			
		||||
    "cancelled": "Annulé",
 | 
			
		||||
    "download_again": "Télécharger à nouveau",
 | 
			
		||||
    "cancel": "Annuler",
 | 
			
		||||
    "filter": "Filtrer les jeux téléchargés",
 | 
			
		||||
    "remove": "Supprimer",
 | 
			
		||||
    "downloading_metadata": "Téléchargement des métadonnées en cours…",
 | 
			
		||||
    "checking_files": "Vérification des fichiers…",
 | 
			
		||||
    "starting_download": "Démarrage du téléchargement…",
 | 
			
		||||
    "remove_from_list": "Retirer",
 | 
			
		||||
    "delete": "Supprimer le programme d'installation",
 | 
			
		||||
    "delete_modal_description": "Cela supprimera tous les fichiers d'installation de votre ordinateur",
 | 
			
		||||
    "delete_modal_title": "Es-tu sûr?",
 | 
			
		||||
    "deleting": "Suppression du programme d'installation…",
 | 
			
		||||
    "install": "Installer"
 | 
			
		||||
  },
 | 
			
		||||
  "settings": {
 | 
			
		||||
    "downloads_path": "Chemin des téléchargements",
 | 
			
		||||
    "change": "Mettre à jour",
 | 
			
		||||
    "notifications": "Notifications",
 | 
			
		||||
    "enable_download_notifications": "Quand un téléchargement est terminé",
 | 
			
		||||
    "enable_repack_list_notifications": "Quand une nouvelle réduction est ajoutée",
 | 
			
		||||
    "telemetry": "Télémétrie",
 | 
			
		||||
    "telemetry_description": "Activer les statistiques d'utilisation anonymes"
 | 
			
		||||
  },
 | 
			
		||||
  "notifications": {
 | 
			
		||||
    "download_complete": "Téléchargement terminé",
 | 
			
		||||
    "game_ready_to_install": "{{title}} est prêt à être installé",
 | 
			
		||||
    "repack_list_updated": "Liste de réductions mise à jour",
 | 
			
		||||
    "repack_count_one": "{{count}} réduction ajoutée",
 | 
			
		||||
    "repack_count_other": "{{count}} réductions ajoutées"
 | 
			
		||||
  },
 | 
			
		||||
  "system_tray": {
 | 
			
		||||
    "open": "Ouvrir Hydra",
 | 
			
		||||
    "quit": "Quitter"
 | 
			
		||||
  },
 | 
			
		||||
  "game_card": {
 | 
			
		||||
    "no_downloads": "Aucun téléchargement disponible"
 | 
			
		||||
  },
 | 
			
		||||
  "binary_not_found_modal": {
 | 
			
		||||
    "description": "Les exécutables Wine ou Lutris sont introuvables sur votre système",
 | 
			
		||||
    "instructions": "Vérifiez la bonne façon d'installer l'un d'entre eux sur votre distribution Linux afin que le jeu puisse fonctionner normalement",
 | 
			
		||||
    "title": "Programmes non installés"
 | 
			
		||||
  },
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "next_page": "Page suivante",
 | 
			
		||||
    "previous_page": "Page précédente"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								src/locales/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/locales/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export { default as en } from "./en/translation.json";
 | 
			
		||||
export { default as pt } from "./pt/translation.json";
 | 
			
		||||
export { default as es } from "./es/translation.json";
 | 
			
		||||
export { default as fr } from "./fr/translation.json";
 | 
			
		||||
							
								
								
									
										141
									
								
								src/locales/pt/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/locales/pt/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
{
 | 
			
		||||
  "home": {
 | 
			
		||||
    "featured": "Destaque",
 | 
			
		||||
    "recently_added": "Novidades",
 | 
			
		||||
    "trending": "Populares",
 | 
			
		||||
    "surprise_me": "Me surpreenda",
 | 
			
		||||
    "no_results": "Nenhum resultado encontrado"
 | 
			
		||||
  },
 | 
			
		||||
  "sidebar": {
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Downloads",
 | 
			
		||||
    "settings": "Configurações",
 | 
			
		||||
    "my_library": "Minha biblioteca",
 | 
			
		||||
    "downloading_metadata": "{{title}} (Baixando metadados…)",
 | 
			
		||||
    "checking_files": "{{title}} ({{percentage}} - Verificando arquivos…)",
 | 
			
		||||
    "paused": "{{title}} (Pausado)",
 | 
			
		||||
    "downloading": "{{title}} ({{percentage}} - Baixando…)",
 | 
			
		||||
    "filter": "Filtrar biblioteca",
 | 
			
		||||
    "home": "Início",
 | 
			
		||||
    "follow_us": "Acompanhe-nos"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Buscar",
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Downloads",
 | 
			
		||||
    "search_results": "Resultados da busca",
 | 
			
		||||
    "settings": "Configurações",
 | 
			
		||||
    "home": "Início"
 | 
			
		||||
  },
 | 
			
		||||
  "bottom_panel": {
 | 
			
		||||
    "no_downloads_in_progress": "Sem downloads em andamento",
 | 
			
		||||
    "downloading_metadata": "Baixando metadados de {{title}}…",
 | 
			
		||||
    "checking_files": "Verificando arquivos de {{title}}… ({{percentage}} completo)",
 | 
			
		||||
    "downloading": "Baixando {{title}}… ({{percentage}} completo) - Conclusão {{eta}} - {{speed}}"
 | 
			
		||||
  },
 | 
			
		||||
  "game_details": {
 | 
			
		||||
    "open_download_options": "Ver opções de download",
 | 
			
		||||
    "download_options_zero": "Sem opções de download",
 | 
			
		||||
    "download_options_one": "{{count}} opção de download",
 | 
			
		||||
    "download_options_other": "{{count}} opções de download",
 | 
			
		||||
    "updated_at": "Atualizado {{updated_at}}",
 | 
			
		||||
    "resume": "Resumir",
 | 
			
		||||
    "pause": "Pausar",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "remove": "Remover",
 | 
			
		||||
    "remove_from_list": "Remover",
 | 
			
		||||
    "space_left_on_disk": "{{space}} livres em disco",
 | 
			
		||||
    "eta": "Conclusão {{eta}}",
 | 
			
		||||
    "downloading_metadata": "Baixando metadados…",
 | 
			
		||||
    "checking_files": "Verificando arquivos…",
 | 
			
		||||
    "filter": "Filtrar repacks",
 | 
			
		||||
    "requirements": "Requisitos do sistema",
 | 
			
		||||
    "minimum": "Mínimos",
 | 
			
		||||
    "recommended": "Recomendados",
 | 
			
		||||
    "no_minimum_requirements": "{{title}} não possui informações de requisitos mínimos",
 | 
			
		||||
    "no_recommended_requirements": "{{title}} não possui informações de requisitos recomendados",
 | 
			
		||||
    "paused_progress": "{{progress}} (Pausado)",
 | 
			
		||||
    "release_date": "Lançado em {{date}}",
 | 
			
		||||
    "publisher": "Publicado por {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copiar link",
 | 
			
		||||
    "copied_link_to_clipboard": "Link copiado",
 | 
			
		||||
    "hours": "horas",
 | 
			
		||||
    "minutes": "minutos",
 | 
			
		||||
    "accuracy": "{{accuracy}}% de precisão",
 | 
			
		||||
    "add_to_library": "Adicionar à biblioteca",
 | 
			
		||||
    "remove_from_library": "Remover da biblioteca",
 | 
			
		||||
    "no_downloads": "Nenhum download disponível",
 | 
			
		||||
    "play_time": "Jogado por {{amount}}",
 | 
			
		||||
    "next_suggestion": "Próxima sugestão",
 | 
			
		||||
    "install": "Instalar",
 | 
			
		||||
    "last_time_played": "Jogou por último {{period}}",
 | 
			
		||||
    "play": "Jogar",
 | 
			
		||||
    "not_played_yet": "Você ainda não jogou {{title}}",
 | 
			
		||||
    "close": "Fechar",
 | 
			
		||||
    "deleting": "Excluindo instalador…",
 | 
			
		||||
    "playing_now": "Jogando agora"
 | 
			
		||||
  },
 | 
			
		||||
  "activation": {
 | 
			
		||||
    "title": "Ativação",
 | 
			
		||||
    "installation_id": "ID da instalação:",
 | 
			
		||||
    "enter_activation_code": "Insira seu código de ativação",
 | 
			
		||||
    "message": "Se você não sabe onde conseguir o código, talvez você não devesse estar aqui.",
 | 
			
		||||
    "activate": "Ativar",
 | 
			
		||||
    "loading": "Carregando…"
 | 
			
		||||
  },
 | 
			
		||||
  "downloads": {
 | 
			
		||||
    "resume": "Resumir",
 | 
			
		||||
    "pause": "Pausar",
 | 
			
		||||
    "eta": "Conclusão {{eta}}",
 | 
			
		||||
    "paused": "Pausado",
 | 
			
		||||
    "verifying": "Verificando…",
 | 
			
		||||
    "completed_at": "Concluído em {{date}}",
 | 
			
		||||
    "completed": "Concluído",
 | 
			
		||||
    "cancelled": "Cancelado",
 | 
			
		||||
    "download_again": "Baixar novamente",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "filter": "Filtrar jogos baixados",
 | 
			
		||||
    "remove": "Remover",
 | 
			
		||||
    "downloading_metadata": "Baixando metadados…",
 | 
			
		||||
    "checking_files": "Verificando arquivos…",
 | 
			
		||||
    "starting_download": "Iniciando download…",
 | 
			
		||||
    "remove_from_list": "Remover",
 | 
			
		||||
    "delete": "Remover instalador",
 | 
			
		||||
    "delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
 | 
			
		||||
    "delete_modal_title": "Tem certeza?",
 | 
			
		||||
    "deleting": "Excluindo instalador…",
 | 
			
		||||
    "install": "Instalar"
 | 
			
		||||
  },
 | 
			
		||||
  "settings": {
 | 
			
		||||
    "downloads_path": "Diretório dos downloads",
 | 
			
		||||
    "change": "Mudar",
 | 
			
		||||
    "notifications": "Notificações",
 | 
			
		||||
    "enable_download_notifications": "Quando um download for concluído",
 | 
			
		||||
    "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
 | 
			
		||||
    "telemetry": "Telemetria",
 | 
			
		||||
    "telemetry_description": "Habilitar estatísticas de uso anônimas"
 | 
			
		||||
  },
 | 
			
		||||
  "notifications": {
 | 
			
		||||
    "download_complete": "Download concluído",
 | 
			
		||||
    "game_ready_to_install": "{{title}} está pronto para ser instalado",
 | 
			
		||||
    "repack_list_updated": "Lista de repacks atualizada",
 | 
			
		||||
    "repack_count_one": "{{count}} novo repack",
 | 
			
		||||
    "repack_count_other": "{{count}} novos repacks"
 | 
			
		||||
  },
 | 
			
		||||
  "system_tray": {
 | 
			
		||||
    "open": "Abrir Hydra",
 | 
			
		||||
    "quit": "Fechar"
 | 
			
		||||
  },
 | 
			
		||||
  "game_card": {
 | 
			
		||||
    "no_downloads": "Sem downloads disponíveis"
 | 
			
		||||
  },
 | 
			
		||||
  "binary_not_found_modal": {
 | 
			
		||||
    "title": "Programas não instalados",
 | 
			
		||||
    "description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
 | 
			
		||||
    "instructions": "Verifique a forma correta de instalar algum deles na sua distro Linux para que o jogo possa ser executado normalmente"
 | 
			
		||||
  },
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "next_page": "Próxima página",
 | 
			
		||||
    "previous_page": "Página anterior"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								src/main/constants.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/main/constants.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,55 @@
 | 
			
		|||
import { app } from "electron";
 | 
			
		||||
import os from "node:os";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
export const repackersOn1337x = [
 | 
			
		||||
  "DODI",
 | 
			
		||||
  "FitGirl",
 | 
			
		||||
  "0xEMPRESS",
 | 
			
		||||
  "KaOsKrew",
 | 
			
		||||
  "TinyRepacks",
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const repackers = [
 | 
			
		||||
  ...repackersOn1337x,
 | 
			
		||||
  "Xatab",
 | 
			
		||||
  "CPG",
 | 
			
		||||
  "TinyRepacks",
 | 
			
		||||
  "GOG",
 | 
			
		||||
  "onlinefix",
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const months = [
 | 
			
		||||
  "Jan",
 | 
			
		||||
  "Feb",
 | 
			
		||||
  "Mar",
 | 
			
		||||
  "Apr",
 | 
			
		||||
  "May",
 | 
			
		||||
  "Jun",
 | 
			
		||||
  "Jul",
 | 
			
		||||
  "Aug",
 | 
			
		||||
  "Sep",
 | 
			
		||||
  "Oct",
 | 
			
		||||
  "Nov",
 | 
			
		||||
  "Dec",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export enum GameStatus {
 | 
			
		||||
  Seeding = "seeding",
 | 
			
		||||
  Downloading = "downloading",
 | 
			
		||||
  Paused = "paused",
 | 
			
		||||
  CheckingFiles = "checking_files",
 | 
			
		||||
  DownloadingMetadata = "downloading_metadata",
 | 
			
		||||
  Cancelled = "cancelled",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
 | 
			
		||||
 | 
			
		||||
export const databasePath = path.join(
 | 
			
		||||
  app.getPath("appData"),
 | 
			
		||||
  app.getName(),
 | 
			
		||||
  "hydra.db"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const INSTALLATION_ID_LENGTH = 6;
 | 
			
		||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
 | 
			
		||||
							
								
								
									
										35
									
								
								src/main/data-source.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/main/data-source.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
import { DataSource } from "typeorm";
 | 
			
		||||
import {
 | 
			
		||||
  Game,
 | 
			
		||||
  GameShopCache,
 | 
			
		||||
  ImageCache,
 | 
			
		||||
  Repack,
 | 
			
		||||
  RepackerFriendlyName,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
  MigrationScript,
 | 
			
		||||
  SteamGame,
 | 
			
		||||
} from "@main/entity";
 | 
			
		||||
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
 | 
			
		||||
 | 
			
		||||
import { databasePath } from "./constants";
 | 
			
		||||
 | 
			
		||||
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
 | 
			
		||||
  new DataSource({
 | 
			
		||||
    type: "better-sqlite3",
 | 
			
		||||
    database: databasePath,
 | 
			
		||||
    entities: [
 | 
			
		||||
      Game,
 | 
			
		||||
      ImageCache,
 | 
			
		||||
      Repack,
 | 
			
		||||
      RepackerFriendlyName,
 | 
			
		||||
      UserPreferences,
 | 
			
		||||
      GameShopCache,
 | 
			
		||||
      MigrationScript,
 | 
			
		||||
      SteamGame,
 | 
			
		||||
    ],
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export const dataSource = createDataSource({
 | 
			
		||||
  synchronize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										32
									
								
								src/main/entity/game-shop-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/main/entity/game-shop-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
import type { GameShop } from "@types";
 | 
			
		||||
 | 
			
		||||
@Entity("game_shop_cache")
 | 
			
		||||
export class GameShopCache {
 | 
			
		||||
  @PrimaryColumn("text", { unique: true })
 | 
			
		||||
  objectID: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  shop: GameShop;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  serializedData: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  howLongToBeatSerializedData: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  language: string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								src/main/entity/game.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/main/entity/game.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,69 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
  OneToOne,
 | 
			
		||||
  JoinColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
import type { GameShop } from "@types";
 | 
			
		||||
import { Repack } from "./repack.entity";
 | 
			
		||||
 | 
			
		||||
@Entity("game")
 | 
			
		||||
export class Game {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  objectID: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  title: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  iconUrl: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  folderName: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  downloadPath: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  executablePath: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column("int", { default: 0 })
 | 
			
		||||
  playTimeInMilliseconds: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  shop: GameShop;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  status: string;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  progress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  fileVerificationProgress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("int", { default: 0 })
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  lastTimePlayed: Date | null;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  fileSize: number;
 | 
			
		||||
 | 
			
		||||
  @OneToOne(() => Repack, { nullable: true })
 | 
			
		||||
  @JoinColumn()
 | 
			
		||||
  repack: Repack;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								src/main/entity/image-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/main/entity/image-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("image_cache")
 | 
			
		||||
export class ImageCache {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  url: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  data: string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								src/main/entity/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/main/entity/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
export * from "./game.entity";
 | 
			
		||||
export * from "./image-cache.entity";
 | 
			
		||||
export * from "./repack.entity";
 | 
			
		||||
export * from "./repacker-friendly-name.entity";
 | 
			
		||||
export * from "./user-preferences.entity";
 | 
			
		||||
export * from "./game-shop-cache.entity";
 | 
			
		||||
export * from "./migration-script.entity";
 | 
			
		||||
export * from "./steam-game.entity";
 | 
			
		||||
							
								
								
									
										22
									
								
								src/main/entity/migration-script.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/main/entity/migration-script.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("migration_script")
 | 
			
		||||
export class MigrationScript {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  version: string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								src/main/entity/repack.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/main/entity/repack.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("repack")
 | 
			
		||||
export class Repack {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  title: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  magnet: string;
 | 
			
		||||
 | 
			
		||||
  @Column("int")
 | 
			
		||||
  page: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  repacker: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  fileSize: string;
 | 
			
		||||
 | 
			
		||||
  @Column("datetime")
 | 
			
		||||
  uploadDate: Date | string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								src/main/entity/repacker-friendly-name.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/main/entity/repacker-friendly-name.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("repacker_friendly_name")
 | 
			
		||||
export class RepackerFriendlyName {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { unique: true })
 | 
			
		||||
  name: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  friendlyName: string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/main/entity/steam-game.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/main/entity/steam-game.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import { Column, Entity, PrimaryColumn } from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("steam_game")
 | 
			
		||||
export class SteamGame {
 | 
			
		||||
  @PrimaryColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column()
 | 
			
		||||
  name: string;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/main/entity/user-preferences.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/main/entity/user-preferences.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("user_preferences")
 | 
			
		||||
export class UserPreferences {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  downloadsPath: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { default: "en" })
 | 
			
		||||
  language: string;
 | 
			
		||||
 | 
			
		||||
  @Column("boolean", { default: false })
 | 
			
		||||
  downloadNotificationsEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
  @Column("boolean", { default: false })
 | 
			
		||||
  repackUpdatesNotificationsEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
  @Column("boolean", { default: true })
 | 
			
		||||
  telemetryEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										117
									
								
								src/main/events/catalogue/get-catalogue.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/main/events/catalogue/get-catalogue.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
 | 
			
		||||
import type { CatalogueCategory, 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");
 | 
			
		||||
 | 
			
		||||
interface GetStringForLookup {
 | 
			
		||||
  (index: number): string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getCatalogue = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  category: CatalogueCategory
 | 
			
		||||
) => {
 | 
			
		||||
  const getStringForLookup = (index: number): string => {
 | 
			
		||||
    const repack = repacks[index];
 | 
			
		||||
    const formatter =
 | 
			
		||||
      repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
 | 
			
		||||
 | 
			
		||||
    return formatName(formatter(repack.title));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!repacks.length) return [];
 | 
			
		||||
 | 
			
		||||
  const resultSize = 12;
 | 
			
		||||
 | 
			
		||||
  if (category === "trending") {
 | 
			
		||||
    return getTrendingCatalogue(resultSize);
 | 
			
		||||
  } else {
 | 
			
		||||
    return getRecentlyAddedCatalogue(
 | 
			
		||||
      resultSize,
 | 
			
		||||
      resultSize,
 | 
			
		||||
      getStringForLookup
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getTrendingCatalogue = async (
 | 
			
		||||
  resultSize: number
 | 
			
		||||
): Promise<CatalogueEntry[]> => {
 | 
			
		||||
  const results: CatalogueEntry[] = [];
 | 
			
		||||
  const trendingGames = await requestSteam250("/30day");
 | 
			
		||||
  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,
 | 
			
		||||
  requestSize: number,
 | 
			
		||||
  getStringForLookup: GetStringForLookup
 | 
			
		||||
): Promise<CatalogueEntry[]> => {
 | 
			
		||||
  let lookupRequest = [];
 | 
			
		||||
  const results: CatalogueEntry[] = [];
 | 
			
		||||
 | 
			
		||||
  for (let i = 0; results.length < resultSize; i++) {
 | 
			
		||||
    const stringForLookup = getStringForLookup(i);
 | 
			
		||||
 | 
			
		||||
    if (!stringForLookup) {
 | 
			
		||||
      i++;
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    lookupRequest.push(searchGames({ query: stringForLookup }));
 | 
			
		||||
 | 
			
		||||
    if (lookupRequest.length < requestSize) {
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const games = (await Promise.all(lookupRequest)).map((value) =>
 | 
			
		||||
      value.at(0)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    for (const game of games) {
 | 
			
		||||
      const isAlreadyIncluded = results.some(
 | 
			
		||||
        (result) => result.objectID === game?.objectID
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (!game || !game.repacks.length || isAlreadyIncluded) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      results.push(game);
 | 
			
		||||
    }
 | 
			
		||||
    lookupRequest = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return results.slice(0, resultSize);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getCatalogue, {
 | 
			
		||||
  name: "getCatalogue",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										72
									
								
								src/main/events/catalogue/get-game-shop-details.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/main/events/catalogue/get-game-shop-details.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,72 @@
 | 
			
		|||
import { gameShopCacheRepository } from "@main/repository";
 | 
			
		||||
import { getSteamAppDetails } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchRepacks } from "../helpers/search-games";
 | 
			
		||||
 | 
			
		||||
const getGameShopDetails = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  shop: GameShop,
 | 
			
		||||
  language: string
 | 
			
		||||
): Promise<ShopDetails | null> => {
 | 
			
		||||
  if (shop === "steam") {
 | 
			
		||||
    const cachedData = await gameShopCacheRepository.findOne({
 | 
			
		||||
      where: { objectID, language },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const result = Promise.all([
 | 
			
		||||
      getSteamAppDetails(objectID, "english"),
 | 
			
		||||
      getSteamAppDetails(objectID, language),
 | 
			
		||||
    ]).then(([appDetails, localizedAppDetails]) => {
 | 
			
		||||
      if (appDetails && localizedAppDetails) {
 | 
			
		||||
        gameShopCacheRepository.upsert(
 | 
			
		||||
          {
 | 
			
		||||
            objectID,
 | 
			
		||||
            shop: "steam",
 | 
			
		||||
            language,
 | 
			
		||||
            serializedData: JSON.stringify({
 | 
			
		||||
              ...localizedAppDetails,
 | 
			
		||||
              name: appDetails.name,
 | 
			
		||||
            }),
 | 
			
		||||
          },
 | 
			
		||||
          ["objectID"]
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return [appDetails, localizedAppDetails];
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const cachedGame = cachedData?.serializedData
 | 
			
		||||
      ? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
 | 
			
		||||
      : null;
 | 
			
		||||
 | 
			
		||||
    if (cachedGame) {
 | 
			
		||||
      return {
 | 
			
		||||
        ...cachedGame,
 | 
			
		||||
        repacks: searchRepacks(cachedGame.name),
 | 
			
		||||
        objectID,
 | 
			
		||||
      } as ShopDetails;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result.then(([appDetails, localizedAppDetails]) => {
 | 
			
		||||
      if (!appDetails || !localizedAppDetails) return null;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        ...localizedAppDetails,
 | 
			
		||||
        name: appDetails.name,
 | 
			
		||||
        repacks: searchRepacks(appDetails.name),
 | 
			
		||||
        objectID,
 | 
			
		||||
      } as ShopDetails;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  throw new Error("Not implemented");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getGameShopDetails, {
 | 
			
		||||
  name: "getGameShopDetails",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										44
									
								
								src/main/events/catalogue/get-games.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/main/events/catalogue/get-games.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
import type { CatalogueEntry, GameShop } 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");
 | 
			
		||||
 | 
			
		||||
const getGames = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  take?: number,
 | 
			
		||||
  cursor = 0
 | 
			
		||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
 | 
			
		||||
  const results: CatalogueEntry[] = [];
 | 
			
		||||
 | 
			
		||||
  let i = 0 + cursor;
 | 
			
		||||
 | 
			
		||||
  if (!steamGames.length) return [];
 | 
			
		||||
 | 
			
		||||
  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 };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getGames, {
 | 
			
		||||
  name: "getGames",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										48
									
								
								src/main/events/catalogue/get-how-long-to-beat.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/main/events/catalogue/get-how-long-to-beat.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
import type { GameShop, HowLongToBeatCategory } from "@types";
 | 
			
		||||
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { gameShopCacheRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
const getHowLongToBeat = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  shop: GameShop,
 | 
			
		||||
  title: string
 | 
			
		||||
): Promise<HowLongToBeatCategory[] | null> => {
 | 
			
		||||
  const searchHowLongToBeatPromise = searchHowLongToBeat(title);
 | 
			
		||||
 | 
			
		||||
  const gameShopCache = await gameShopCacheRepository.findOne({
 | 
			
		||||
    where: { objectID, shop },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
 | 
			
		||||
    ? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
 | 
			
		||||
    : null;
 | 
			
		||||
  if (howLongToBeatCachedData) return howLongToBeatCachedData;
 | 
			
		||||
 | 
			
		||||
  return searchHowLongToBeatPromise.then(async (response) => {
 | 
			
		||||
    const game = response.data.find(
 | 
			
		||||
      (game) => game.profile_steam === Number(objectID)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!game) return null;
 | 
			
		||||
    const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
 | 
			
		||||
 | 
			
		||||
    gameShopCacheRepository.upsert(
 | 
			
		||||
      {
 | 
			
		||||
        objectID,
 | 
			
		||||
        shop,
 | 
			
		||||
        howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
 | 
			
		||||
      },
 | 
			
		||||
      ["objectID"]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return howLongToBeat;
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getHowLongToBeat, {
 | 
			
		||||
  name: "getHowLongToBeat",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								src/main/events/catalogue/get-random-game.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/main/events/catalogue/get-random-game.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import { shuffle } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
import { getRandomSteam250List } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchGames, searchRepacks } from "../helpers/search-games";
 | 
			
		||||
import { formatName } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
			
		||||
  return getRandomSteam250List().then(async (games) => {
 | 
			
		||||
    const shuffledList = shuffle(games);
 | 
			
		||||
 | 
			
		||||
    for (const game of shuffledList) {
 | 
			
		||||
      const repacks = searchRepacks(formatName(game.title));
 | 
			
		||||
 | 
			
		||||
      if (repacks.length) {
 | 
			
		||||
        const results = await searchGames({ query: game.title });
 | 
			
		||||
 | 
			
		||||
        if (results.length) {
 | 
			
		||||
          return results[0].objectID;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getRandomGame, {
 | 
			
		||||
  name: "getRandomGame",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/events/catalogue/search-games.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/catalogue/search-games.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchGames } from "../helpers/search-games";
 | 
			
		||||
 | 
			
		||||
registerEvent(
 | 
			
		||||
  (_event: Electron.IpcMainInvokeEvent, query: string) =>
 | 
			
		||||
    searchGames({ query, take: 12 }),
 | 
			
		||||
  {
 | 
			
		||||
    name: "searchGames",
 | 
			
		||||
    memoize: true,
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/events/hardware/get-disk-free-space.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/hardware/get-disk-free-space.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import checkDiskSpace from "check-disk-space";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
 | 
			
		||||
const getDiskFreeSpace = async (_event: Electron.IpcMainInvokeEvent) =>
 | 
			
		||||
  checkDiskSpace(await getDownloadsPath());
 | 
			
		||||
 | 
			
		||||
registerEvent(getDiskFreeSpace, {
 | 
			
		||||
  name: "getDiskFreeSpace",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										44
									
								
								src/main/events/helpers/generate-lutris-yaml.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/main/events/helpers/generate-lutris-yaml.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
import { Document as YMLDocument } from "yaml";
 | 
			
		||||
import { Game } from "@main/entity";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
export const generateYML = (game: Game) => {
 | 
			
		||||
  const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
 | 
			
		||||
 | 
			
		||||
  const doc = new YMLDocument({
 | 
			
		||||
    name: game.title,
 | 
			
		||||
    game_slug: slugifiedGameTitle,
 | 
			
		||||
    slug: `${slugifiedGameTitle}-installer`,
 | 
			
		||||
    version: "Installer",
 | 
			
		||||
    runner: "wine",
 | 
			
		||||
    script: {
 | 
			
		||||
      game: {
 | 
			
		||||
        prefix: "$GAMEDIR",
 | 
			
		||||
        arch: "win64",
 | 
			
		||||
        working_dir: "$GAMEDIR",
 | 
			
		||||
      },
 | 
			
		||||
      installer: [
 | 
			
		||||
        {
 | 
			
		||||
          task: {
 | 
			
		||||
            name: "create_prefix",
 | 
			
		||||
            arch: "win64",
 | 
			
		||||
            prefix: "$GAMEDIR",
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          task: {
 | 
			
		||||
            executable: path.join(
 | 
			
		||||
              game.downloadPath,
 | 
			
		||||
              game.folderName,
 | 
			
		||||
              "setup.exe"
 | 
			
		||||
            ),
 | 
			
		||||
            name: "wineexec",
 | 
			
		||||
            prefix: "$GAMEDIR",
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return doc.toString();
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										15
									
								
								src/main/events/helpers/get-downloads-path.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/main/events/helpers/get-downloads-path.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
import { userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { defaultDownloadsPath } from "@main/constants";
 | 
			
		||||
 | 
			
		||||
export const getDownloadsPath = async () => {
 | 
			
		||||
  const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: 1,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (userPreferences && userPreferences.downloadsPath)
 | 
			
		||||
    return userPreferences.downloadsPath;
 | 
			
		||||
 | 
			
		||||
  return defaultDownloadsPath;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										71
									
								
								src/main/events/helpers/search-games.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/main/events/helpers/search-games.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import flexSearch from "flexsearch";
 | 
			
		||||
import { orderBy } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
import type { GameRepack, GameShop, CatalogueEntry } 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"
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface SearchGamesArgs {
 | 
			
		||||
  query?: string;
 | 
			
		||||
  take?: number;
 | 
			
		||||
  skip?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const searchGames = async ({
 | 
			
		||||
  query,
 | 
			
		||||
  take,
 | 
			
		||||
  skip,
 | 
			
		||||
}: SearchGamesArgs): Promise<CatalogueEntry[]> => {
 | 
			
		||||
  const results = steamGamesIndex
 | 
			
		||||
    .search(formatName(query || ""), { limit: take, offset: skip })
 | 
			
		||||
    .map((index) => {
 | 
			
		||||
      const result = steamGames.at(index as number)!;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        objectID: String(result.id),
 | 
			
		||||
        title: result.name,
 | 
			
		||||
        shop: "steam" as GameShop,
 | 
			
		||||
        cover: getSteamAppAsset("library", String(result.id)),
 | 
			
		||||
        repacks: searchRepacks(result.name),
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  return Promise.all(results).then((resultsWithRepacks) =>
 | 
			
		||||
    orderBy(
 | 
			
		||||
      resultsWithRepacks,
 | 
			
		||||
      [({ repacks }) => repacks.length, "repacks"],
 | 
			
		||||
      ["desc"]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										32
									
								
								src/main/events/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/main/events/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import { defaultDownloadsPath } from "@main/constants";
 | 
			
		||||
import { app, ipcMain } from "electron";
 | 
			
		||||
 | 
			
		||||
import "./catalogue/get-catalogue";
 | 
			
		||||
import "./catalogue/get-game-shop-details";
 | 
			
		||||
import "./catalogue/get-games";
 | 
			
		||||
import "./catalogue/get-how-long-to-beat";
 | 
			
		||||
import "./catalogue/get-random-game";
 | 
			
		||||
import "./catalogue/search-games";
 | 
			
		||||
import "./hardware/get-disk-free-space";
 | 
			
		||||
import "./library/add-game-to-library";
 | 
			
		||||
import "./library/close-game";
 | 
			
		||||
import "./library/delete-game-folder";
 | 
			
		||||
import "./library/get-game-by-object-id";
 | 
			
		||||
import "./library/get-library";
 | 
			
		||||
import "./library/get-repackers-friendly-names";
 | 
			
		||||
import "./library/open-game";
 | 
			
		||||
import "./library/open-game-installer";
 | 
			
		||||
import "./library/remove-game";
 | 
			
		||||
import "./misc/get-or-cache-image";
 | 
			
		||||
import "./misc/open-external";
 | 
			
		||||
import "./misc/show-open-dialog";
 | 
			
		||||
import "./torrenting/cancel-game-download";
 | 
			
		||||
import "./torrenting/pause-game-download";
 | 
			
		||||
import "./torrenting/resume-game-download";
 | 
			
		||||
import "./torrenting/start-game-download";
 | 
			
		||||
import "./user-preferences/get-user-preferences";
 | 
			
		||||
import "./user-preferences/update-user-preferences";
 | 
			
		||||
 | 
			
		||||
ipcMain.handle("ping", () => "pong");
 | 
			
		||||
ipcMain.handle("getVersion", () => app.getVersion());
 | 
			
		||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
 | 
			
		||||
							
								
								
									
										29
									
								
								src/main/events/library/add-game-to-library.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/main/events/library/add-game-to-library.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
import type { GameShop } from "@types";
 | 
			
		||||
import { getImageBase64 } from "@main/helpers";
 | 
			
		||||
import { getSteamGameIconUrl } from "@main/services";
 | 
			
		||||
 | 
			
		||||
const addGameToLibrary = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  title: string,
 | 
			
		||||
  gameShop: GameShop,
 | 
			
		||||
  executablePath: string
 | 
			
		||||
) => {
 | 
			
		||||
  const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
 | 
			
		||||
 | 
			
		||||
  return gameRepository.insert({
 | 
			
		||||
    title,
 | 
			
		||||
    iconUrl,
 | 
			
		||||
    objectID,
 | 
			
		||||
    shop: gameShop,
 | 
			
		||||
    executablePath,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(addGameToLibrary, {
 | 
			
		||||
  name: "addGameToLibrary",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										35
									
								
								src/main/events/library/close-game.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/main/events/library/close-game.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { getProcesses } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
const closeGame = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const processes = await getProcesses();
 | 
			
		||||
  const game = await gameRepository.findOne({ where: { id: gameId } });
 | 
			
		||||
 | 
			
		||||
  const gameProcess = processes.find((runningProcess) => {
 | 
			
		||||
    const basename = path.win32.basename(game.executablePath);
 | 
			
		||||
    const basenameWithoutExtension = path.win32.basename(
 | 
			
		||||
      game.executablePath,
 | 
			
		||||
      path.extname(game.executablePath)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (process.platform === "win32") {
 | 
			
		||||
      return runningProcess.name === basename;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return [basename, basenameWithoutExtension].includes(runningProcess.name);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (gameProcess) return process.kill(gameProcess.pid);
 | 
			
		||||
  return false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(closeGame, {
 | 
			
		||||
  name: "closeGame",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										47
									
								
								src/main/events/library/delete-game-folder.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/main/events/library/delete-game-folder.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
 | 
			
		||||
import { GameStatus } from "@main/constants";
 | 
			
		||||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
import { logger } from "@main/services";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const deleteGameFolder = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
      status: GameStatus.Cancelled,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
 | 
			
		||||
  if (game.folderName) {
 | 
			
		||||
    const folderPath = path.join(await getDownloadsPath(), game.folderName);
 | 
			
		||||
 | 
			
		||||
    if (fs.existsSync(folderPath)) {
 | 
			
		||||
      return new Promise((resolve, reject) => {
 | 
			
		||||
        fs.rm(
 | 
			
		||||
          folderPath,
 | 
			
		||||
          { recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
 | 
			
		||||
          (error) => {
 | 
			
		||||
            if (error) {
 | 
			
		||||
              logger.error(error);
 | 
			
		||||
              reject();
 | 
			
		||||
            }
 | 
			
		||||
            resolve(null);
 | 
			
		||||
          }
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(deleteGameFolder, {
 | 
			
		||||
  name: "deleteGameFolder",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										20
									
								
								src/main/events/library/get-game-by-object-id.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/main/events/library/get-game-by-object-id.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const getGameByObjectID = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  objectID: string
 | 
			
		||||
) =>
 | 
			
		||||
  gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      objectID,
 | 
			
		||||
    },
 | 
			
		||||
    relations: {
 | 
			
		||||
      repack: true,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
registerEvent(getGameByObjectID, {
 | 
			
		||||
  name: "getGameByObjectID",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										30
									
								
								src/main/events/library/get-library.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/main/events/library/get-library.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
import { GameStatus } from "@main/constants";
 | 
			
		||||
 | 
			
		||||
import { searchRepacks } from "../helpers/search-games";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { sortBy } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
 | 
			
		||||
  gameRepository
 | 
			
		||||
    .find({
 | 
			
		||||
      order: {
 | 
			
		||||
        createdAt: "desc",
 | 
			
		||||
      },
 | 
			
		||||
      relations: {
 | 
			
		||||
        repack: true,
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    .then((games) =>
 | 
			
		||||
      sortBy(
 | 
			
		||||
        games.map((game) => ({
 | 
			
		||||
          ...game,
 | 
			
		||||
          repacks: searchRepacks(game.title),
 | 
			
		||||
        })),
 | 
			
		||||
        (game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
registerEvent(getLibrary, {
 | 
			
		||||
  name: "getLibrary",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										12
									
								
								src/main/events/library/get-repackers-friendly-names.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/main/events/library/get-repackers-friendly-names.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
const getRepackersFriendlyNames = async (_event: Electron.IpcMainInvokeEvent) =>
 | 
			
		||||
  stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
 | 
			
		||||
    return { ...prev, [next.name]: next.friendlyName };
 | 
			
		||||
  }, {});
 | 
			
		||||
 | 
			
		||||
registerEvent(getRepackersFriendlyNames, {
 | 
			
		||||
  name: "getRepackersFriendlyNames",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										58
									
								
								src/main/events/library/open-game-installer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/main/events/library/open-game-installer.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
import { generateYML } from "../helpers/generate-lutris-yaml";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
import { writeFile } from "node:fs/promises";
 | 
			
		||||
import { spawnSync, exec } from "node:child_process";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { shell } from "electron";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
 | 
			
		||||
const openGameInstaller = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({ where: { id: gameId } });
 | 
			
		||||
 | 
			
		||||
  if (!game) return true;
 | 
			
		||||
 | 
			
		||||
  const gamePath = path.join(
 | 
			
		||||
    game.downloadPath ?? (await getDownloadsPath()),
 | 
			
		||||
    game.folderName
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!fs.existsSync(gamePath)) {
 | 
			
		||||
    await gameRepository.delete({ id: gameId });
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const setupPath = path.join(gamePath, "setup.exe");
 | 
			
		||||
  if (!fs.existsSync(setupPath)) {
 | 
			
		||||
    shell.openPath(gamePath);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (process.platform === "win32") {
 | 
			
		||||
    shell.openPath(setupPath);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (spawnSync("which", ["lutris"]).status === 0) {
 | 
			
		||||
    const ymlPath = path.join(gamePath, "setup.yml");
 | 
			
		||||
    await writeFile(ymlPath, generateYML(game));
 | 
			
		||||
    exec(`lutris --install "${ymlPath}"`);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (spawnSync("which", ["wine"]).status === 0) {
 | 
			
		||||
    exec(`wine "${setupPath}"`);
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(openGameInstaller, {
 | 
			
		||||
  name: "openGameInstaller",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										18
									
								
								src/main/events/library/open-game.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/main/events/library/open-game.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { shell } from "electron";
 | 
			
		||||
 | 
			
		||||
const openGame = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number,
 | 
			
		||||
  executablePath: string
 | 
			
		||||
) => {
 | 
			
		||||
  await gameRepository.update({ id: gameId }, { executablePath });
 | 
			
		||||
 | 
			
		||||
  shell.openPath(executablePath);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(openGame, {
 | 
			
		||||
  name: "openGame",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/events/library/remove-game.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/library/remove-game.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { gameRepository } from "../../repository";
 | 
			
		||||
 | 
			
		||||
const removeGame = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => gameRepository.delete({ id: gameId });
 | 
			
		||||
 | 
			
		||||
registerEvent(removeGame, {
 | 
			
		||||
  name: "removeGame",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										37
									
								
								src/main/events/misc/get-or-cache-image.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/main/events/misc/get-or-cache-image.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import { imageCacheRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { getImageBase64 } from "@main/helpers";
 | 
			
		||||
import { logger } from "@main/services";
 | 
			
		||||
 | 
			
		||||
const getOrCacheImage = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  url: string
 | 
			
		||||
) => {
 | 
			
		||||
  const cache = await imageCacheRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      url,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (cache) return cache.data;
 | 
			
		||||
 | 
			
		||||
  getImageBase64(url).then((data) =>
 | 
			
		||||
    imageCacheRepository
 | 
			
		||||
      .save({
 | 
			
		||||
        url,
 | 
			
		||||
        data,
 | 
			
		||||
      })
 | 
			
		||||
      .catch(() => {
 | 
			
		||||
        logger.error(`Failed to cache image "${url}"`, {
 | 
			
		||||
          method: "getOrCacheImage",
 | 
			
		||||
        });
 | 
			
		||||
      })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return url;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getOrCacheImage, {
 | 
			
		||||
  name: "getOrCacheImage",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										9
									
								
								src/main/events/misc/open-external.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/main/events/misc/open-external.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import { shell } from "electron";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const openExternal = async (_event: Electron.IpcMainInvokeEvent, src: string) =>
 | 
			
		||||
  shell.openExternal(src);
 | 
			
		||||
 | 
			
		||||
registerEvent(openExternal, {
 | 
			
		||||
  name: "openExternal",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										12
									
								
								src/main/events/misc/show-open-dialog.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/main/events/misc/show-open-dialog.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import { dialog } from "electron";
 | 
			
		||||
import { WindowManager } from "@main/services";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const showOpenDialog = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  options: Electron.OpenDialogOptions
 | 
			
		||||
) => dialog.showOpenDialog(WindowManager.mainWindow, options);
 | 
			
		||||
 | 
			
		||||
registerEvent(showOpenDialog, {
 | 
			
		||||
  name: "showOpenDialog",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										39
									
								
								src/main/events/register-event.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/main/events/register-event.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
import { ipcMain } from "electron";
 | 
			
		||||
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
interface EventArgs {
 | 
			
		||||
  name: string;
 | 
			
		||||
  memoize?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const registerEvent = (
 | 
			
		||||
  listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
 | 
			
		||||
  { name, memoize = false }: EventArgs
 | 
			
		||||
) => {
 | 
			
		||||
  ipcMain.handle(name, (event: Electron.IpcMainInvokeEvent, ...args) => {
 | 
			
		||||
    const eventResults = stateManager.getValue("eventResults");
 | 
			
		||||
    const keys = Array.from(eventResults.keys());
 | 
			
		||||
 | 
			
		||||
    const key = [name, args] as [string, any[]];
 | 
			
		||||
 | 
			
		||||
    const memoizationKey = keys.find(([memoizedEvent, memoizedArgs]) => {
 | 
			
		||||
      const sameEvent = name === memoizedEvent;
 | 
			
		||||
      const sameArgs = memoizedArgs.every((arg, index) => arg === args[index]);
 | 
			
		||||
 | 
			
		||||
      return sameEvent && sameArgs;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (memoizationKey) return eventResults.get(memoizationKey);
 | 
			
		||||
 | 
			
		||||
    return Promise.resolve(listener(event, ...args)).then((result) => {
 | 
			
		||||
      if (memoize) {
 | 
			
		||||
        eventResults.set(key, JSON.parse(JSON.stringify(result)));
 | 
			
		||||
        stateManager.setValue("eventResults", eventResults);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!result) return result;
 | 
			
		||||
      return JSON.parse(JSON.stringify(result));
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										53
									
								
								src/main/events/torrenting/cancel-game-download.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/main/events/torrenting/cancel-game-download.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
import { GameStatus } from "@main/constants";
 | 
			
		||||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { WindowManager, writePipe } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
const cancelGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
        GameStatus.Paused,
 | 
			
		||||
        GameStatus.Seeding,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
 | 
			
		||||
  gameRepository
 | 
			
		||||
    .update(
 | 
			
		||||
      {
 | 
			
		||||
        id: game.id,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        status: GameStatus.Cancelled,
 | 
			
		||||
        downloadPath: null,
 | 
			
		||||
        bytesDownloaded: 0,
 | 
			
		||||
        progress: 0,
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
    .then((result) => {
 | 
			
		||||
      if (
 | 
			
		||||
        game.status !== GameStatus.Paused &&
 | 
			
		||||
        game.status !== GameStatus.Seeding
 | 
			
		||||
      ) {
 | 
			
		||||
        writePipe.write({ action: "cancel" });
 | 
			
		||||
        if (result.affected) WindowManager.mainWindow.setProgressBar(-1);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(cancelGameDownload, {
 | 
			
		||||
  name: "cancelGameDownload",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										34
									
								
								src/main/events/torrenting/pause-game-download.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/main/events/torrenting/pause-game-download.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { WindowManager, writePipe } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { GameStatus } from "../../constants";
 | 
			
		||||
import { gameRepository } from "../../repository";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
const pauseGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  await gameRepository
 | 
			
		||||
    .update(
 | 
			
		||||
      {
 | 
			
		||||
        id: gameId,
 | 
			
		||||
        status: In([
 | 
			
		||||
          GameStatus.Downloading,
 | 
			
		||||
          GameStatus.DownloadingMetadata,
 | 
			
		||||
          GameStatus.CheckingFiles,
 | 
			
		||||
        ]),
 | 
			
		||||
      },
 | 
			
		||||
      { status: GameStatus.Paused }
 | 
			
		||||
    )
 | 
			
		||||
    .then((result) => {
 | 
			
		||||
      if (result.affected) {
 | 
			
		||||
        writePipe.write({ action: "pause" });
 | 
			
		||||
        WindowManager.mainWindow.setProgressBar(-1);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(pauseGameDownload, {
 | 
			
		||||
  name: "pauseGameDownload",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										56
									
								
								src/main/events/torrenting/resume-game-download.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/main/events/torrenting/resume-game-download.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { GameStatus } from "../../constants";
 | 
			
		||||
import { gameRepository } from "../../repository";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
import { writePipe } from "@main/services";
 | 
			
		||||
 | 
			
		||||
const resumeGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
    },
 | 
			
		||||
    relations: { repack: true },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
 | 
			
		||||
  writePipe.write({ action: "pause" });
 | 
			
		||||
 | 
			
		||||
  if (game.status === GameStatus.Paused) {
 | 
			
		||||
    const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
 | 
			
		||||
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: gameId,
 | 
			
		||||
      magnet: game.repack.magnet,
 | 
			
		||||
      save_path: downloadsPath,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
      {
 | 
			
		||||
        status: In([
 | 
			
		||||
          GameStatus.Downloading,
 | 
			
		||||
          GameStatus.DownloadingMetadata,
 | 
			
		||||
          GameStatus.CheckingFiles,
 | 
			
		||||
        ]),
 | 
			
		||||
      },
 | 
			
		||||
      { status: GameStatus.Paused }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
      { id: game.id },
 | 
			
		||||
      {
 | 
			
		||||
        status: GameStatus.DownloadingMetadata,
 | 
			
		||||
        downloadPath: downloadsPath,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(resumeGameDownload, {
 | 
			
		||||
  name: "resumeGameDownload",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										110
									
								
								src/main/events/torrenting/start-game-download.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/main/events/torrenting/start-game-download.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,110 @@
 | 
			
		|||
import { getSteamGameIconUrl, writePipe } from "@main/services";
 | 
			
		||||
import { gameRepository, repackRepository } from "@main/repository";
 | 
			
		||||
import { GameStatus } from "@main/constants";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
import type { GameShop } from "@types";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
import { getImageBase64 } from "@main/helpers";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
const startGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  repackId: number,
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  title: string,
 | 
			
		||||
  gameShop: GameShop
 | 
			
		||||
) => {
 | 
			
		||||
  const [game, repack] = await Promise.all([
 | 
			
		||||
    gameRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        objectID,
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    repackRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: repackId,
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  if (!repack) return;
 | 
			
		||||
 | 
			
		||||
  if (game?.status === GameStatus.Downloading) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  writePipe.write({ action: "pause" });
 | 
			
		||||
 | 
			
		||||
  const downloadsPath = game?.downloadPath ?? (await getDownloadsPath());
 | 
			
		||||
 | 
			
		||||
  await gameRepository.update(
 | 
			
		||||
    {
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
    { status: GameStatus.Paused }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (game) {
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
      {
 | 
			
		||||
        id: game.id,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        status: GameStatus.DownloadingMetadata,
 | 
			
		||||
        downloadPath: downloadsPath,
 | 
			
		||||
        repack: { id: repackId },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: game.id,
 | 
			
		||||
      magnet: repack.magnet,
 | 
			
		||||
      save_path: downloadsPath,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    game.status = GameStatus.DownloadingMetadata;
 | 
			
		||||
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: game.id,
 | 
			
		||||
      magnet: repack.magnet,
 | 
			
		||||
      save_path: downloadsPath,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return game;
 | 
			
		||||
  } else {
 | 
			
		||||
    const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
 | 
			
		||||
 | 
			
		||||
    const createdGame = await gameRepository.save({
 | 
			
		||||
      title,
 | 
			
		||||
      iconUrl,
 | 
			
		||||
      objectID,
 | 
			
		||||
      shop: gameShop,
 | 
			
		||||
      status: GameStatus.DownloadingMetadata,
 | 
			
		||||
      downloadPath: downloadsPath,
 | 
			
		||||
      repack: { id: repackId },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: createdGame.id,
 | 
			
		||||
      magnet: repack.magnet,
 | 
			
		||||
      save_path: downloadsPath,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const { repack: _, ...rest } = createdGame;
 | 
			
		||||
 | 
			
		||||
    return rest;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(startGameDownload, {
 | 
			
		||||
  name: "startGameDownload",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/events/user-preferences/get-user-preferences.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/events/user-preferences/get-user-preferences.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import { userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const getUserPreferences = async (_event: Electron.IpcMainInvokeEvent) =>
 | 
			
		||||
  userPreferencesRepository.findOne({
 | 
			
		||||
    where: { id: 1 },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
registerEvent(getUserPreferences, {
 | 
			
		||||
  name: "getUserPreferences",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										21
									
								
								src/main/events/user-preferences/update-user-preferences.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/main/events/user-preferences/update-user-preferences.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import { userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
import type { UserPreferences } from "@types";
 | 
			
		||||
 | 
			
		||||
const updateUserPreferences = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  preferences: Partial<UserPreferences>
 | 
			
		||||
) => {
 | 
			
		||||
  await userPreferencesRepository.upsert(
 | 
			
		||||
    {
 | 
			
		||||
      id: 1,
 | 
			
		||||
      ...preferences,
 | 
			
		||||
    },
 | 
			
		||||
    ["id"]
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(updateUserPreferences, {
 | 
			
		||||
  name: "updateUserPreferences",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										98
									
								
								src/main/helpers/formatters.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/main/helpers/formatters.test.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,98 @@
 | 
			
		|||
import assert from "node:assert/strict";
 | 
			
		||||
import { describe, test } from "node:test";
 | 
			
		||||
import {
 | 
			
		||||
  dodiFormatter,
 | 
			
		||||
  empressFormatter,
 | 
			
		||||
  fitGirlFormatter,
 | 
			
		||||
  kaosKrewFormatter,
 | 
			
		||||
} from "./formatters";
 | 
			
		||||
 | 
			
		||||
describe("testing formatters", () => {
 | 
			
		||||
  describe("testing fitgirl formatter", () => {
 | 
			
		||||
    const fitGirlGames = [
 | 
			
		||||
      "REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
 | 
			
		||||
      "Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
 | 
			
		||||
      "HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
 | 
			
		||||
      "Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
 | 
			
		||||
      "SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
 | 
			
		||||
      "God of Rock (v3110, MULTi11) [FitGirl Repack]",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    test("should format games correctly", () => {
 | 
			
		||||
      assert.equal(fitGirlGames.map(fitGirlFormatter), [
 | 
			
		||||
        "REVEIL",
 | 
			
		||||
        "Dune: Spice Wars - The Ixian Edition",
 | 
			
		||||
        "HUMANKIND: Premium Edition",
 | 
			
		||||
        "Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
 | 
			
		||||
        "SUPER BOMBERMAN R 2",
 | 
			
		||||
        "God of Rock",
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("testing kaoskrew formatter", () => {
 | 
			
		||||
    const kaosKrewGames = [
 | 
			
		||||
      "Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
 | 
			
		||||
      "Remoteness.REPACK-KaOs",
 | 
			
		||||
      "Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
 | 
			
		||||
      "The.Wreck.MULTi5.REPACK-KaOs",
 | 
			
		||||
      "Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
 | 
			
		||||
      "The.World.Of.Others.v1.05.REPACK-KaOs",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    test("should format games correctly", () => {
 | 
			
		||||
      assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
 | 
			
		||||
        "Song Of Horror Complete Edition",
 | 
			
		||||
        "Remoteness",
 | 
			
		||||
        "Persona 5 Royal NSW For PC",
 | 
			
		||||
        "The Wreck",
 | 
			
		||||
        "Nemezis Mysterious Journey III Deluxe Edition",
 | 
			
		||||
        "The World Of Others",
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("testing empress formatter", () => {
 | 
			
		||||
    const empressGames = [
 | 
			
		||||
      "Resident.Evil.4-EMPRESS",
 | 
			
		||||
      "Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
 | 
			
		||||
      "Life.is.Strange.2.Complete.Edition-EMPRESS",
 | 
			
		||||
      "Forza.Horizon.4.PROPER-EMPRESS",
 | 
			
		||||
      "Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
 | 
			
		||||
      "Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    test("should format games correctly", () => {
 | 
			
		||||
      assert.equal(empressGames.map(empressFormatter), [
 | 
			
		||||
        "Resident Evil 4",
 | 
			
		||||
        "Marvels Guardians of the Galaxy",
 | 
			
		||||
        "Life is Strange 2 Complete Edition",
 | 
			
		||||
        "Forza Horizon 4 PROPER",
 | 
			
		||||
        "Just Cause 4 Complete Edition",
 | 
			
		||||
        "Immortals Fenyx Rising",
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe("testing kodi formatter", () => {
 | 
			
		||||
    const dodiGames = [
 | 
			
		||||
      "Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
 | 
			
		||||
      "Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
 | 
			
		||||
      "Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
 | 
			
		||||
      "Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
 | 
			
		||||
      "DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
 | 
			
		||||
      "Outliver: Tribulation [DODI Repack]",
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    test("should format games correctly", () => {
 | 
			
		||||
      assert.equal(dodiGames.map(dodiFormatter), [
 | 
			
		||||
        "Tomb Raider I-III Remastered Starring Lara Croft",
 | 
			
		||||
        "Trail Out: Complete Edition",
 | 
			
		||||
        "Call to Arms - Gates of Hell: Ostfront",
 | 
			
		||||
        "Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
 | 
			
		||||
        "DREDGE: Digital Deluxe Edition",
 | 
			
		||||
        "Outliver: Tribulation",
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										56
									
								
								src/main/helpers/formatters.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/main/helpers/formatters.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
/* 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})+.+/, "");
 | 
			
		||||
							
								
								
									
										85
									
								
								src/main/helpers/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/main/helpers/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,85 @@
 | 
			
		|||
import {
 | 
			
		||||
  removeReleaseYearFromName,
 | 
			
		||||
  removeSymbolsFromName,
 | 
			
		||||
  removeSpecialEditionFromName,
 | 
			
		||||
  empressFormatter,
 | 
			
		||||
  kaosKrewFormatter,
 | 
			
		||||
  fitGirlFormatter,
 | 
			
		||||
  removeDuplicateSpaces,
 | 
			
		||||
  dodiFormatter,
 | 
			
		||||
  removeTrash,
 | 
			
		||||
  xatabFormatter,
 | 
			
		||||
  tinyRepacksFormatter,
 | 
			
		||||
  gogFormatter,
 | 
			
		||||
  onlinefixFormatter,
 | 
			
		||||
} from "./formatters";
 | 
			
		||||
import { months, repackers } from "../constants";
 | 
			
		||||
 | 
			
		||||
export const pipe =
 | 
			
		||||
  <T>(...fns: ((arg: T) => any)[]) =>
 | 
			
		||||
  (arg: T) =>
 | 
			
		||||
    fns.reduce((prev, fn) => fn(prev), arg);
 | 
			
		||||
 | 
			
		||||
export const formatName = pipe<string>(
 | 
			
		||||
  removeTrash,
 | 
			
		||||
  removeReleaseYearFromName,
 | 
			
		||||
  removeSymbolsFromName,
 | 
			
		||||
  removeSpecialEditionFromName,
 | 
			
		||||
  removeDuplicateSpaces,
 | 
			
		||||
  (str) => str.trim()
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const repackerFormatter: Record<
 | 
			
		||||
  (typeof repackers)[number],
 | 
			
		||||
  (title: string) => string
 | 
			
		||||
> = {
 | 
			
		||||
  DODI: dodiFormatter,
 | 
			
		||||
  "0xEMPRESS": empressFormatter,
 | 
			
		||||
  KaOsKrew: kaosKrewFormatter,
 | 
			
		||||
  FitGirl: fitGirlFormatter,
 | 
			
		||||
  Xatab: xatabFormatter,
 | 
			
		||||
  CPG: (title: string) => title,
 | 
			
		||||
  TinyRepacks: tinyRepacksFormatter,
 | 
			
		||||
  GOG: gogFormatter,
 | 
			
		||||
  onlinefix: onlinefixFormatter,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const formatUploadDate = (str: string) => {
 | 
			
		||||
  const date = new Date();
 | 
			
		||||
 | 
			
		||||
  const [month, day, year] = str.split(" ");
 | 
			
		||||
 | 
			
		||||
  date.setMonth(months.indexOf(month.replace(".", "")));
 | 
			
		||||
  date.setDate(Number(day.substring(0, 2)));
 | 
			
		||||
  date.setFullYear(Number("20" + year.replace("'", "")));
 | 
			
		||||
  date.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
  return date;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamAppAsset = (
 | 
			
		||||
  category: "library" | "hero" | "logo" | "icon",
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  clientIcon?: string
 | 
			
		||||
) => {
 | 
			
		||||
  if (category === "library")
 | 
			
		||||
    return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
 | 
			
		||||
 | 
			
		||||
  if (category === "hero")
 | 
			
		||||
    return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
 | 
			
		||||
 | 
			
		||||
  if (category === "logo")
 | 
			
		||||
    return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
 | 
			
		||||
 | 
			
		||||
  return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getImageBase64 = async (url: string) =>
 | 
			
		||||
  fetch(url, { method: "GET" }).then((response) =>
 | 
			
		||||
    response.arrayBuffer().then((buffer) => {
 | 
			
		||||
      return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export * from "./formatters";
 | 
			
		||||
export * from "./ps";
 | 
			
		||||
							
								
								
									
										12
									
								
								src/main/helpers/ps.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/main/helpers/ps.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import psList from "ps-list";
 | 
			
		||||
import { tasklist } from "tasklist";
 | 
			
		||||
 | 
			
		||||
export const getProcesses = async () => {
 | 
			
		||||
  if (process.platform === "win32") {
 | 
			
		||||
    return tasklist().then((tasks) =>
 | 
			
		||||
      tasks.map((task) => ({ ...task, name: task.imageName }))
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return psList();
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										102
									
								
								src/main/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/main/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,102 @@
 | 
			
		|||
import { app, BrowserWindow } from "electron";
 | 
			
		||||
import { init } from "@sentry/electron/main";
 | 
			
		||||
import i18n from "i18next";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import { resolveDatabaseUpdates, WindowManager } from "@main/services";
 | 
			
		||||
import { dataSource } from "@main/data-source";
 | 
			
		||||
import * as resources from "@locales";
 | 
			
		||||
import { userPreferencesRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
const gotTheLock = app.requestSingleInstanceLock();
 | 
			
		||||
if (!gotTheLock) app.quit();
 | 
			
		||||
 | 
			
		||||
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
 | 
			
		||||
  init({
 | 
			
		||||
    dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
 | 
			
		||||
    beforeSend: async (event) => {
 | 
			
		||||
      const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
        where: { id: 1 },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (userPreferences?.telemetryEnabled) return event;
 | 
			
		||||
      return null;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i18n.init({
 | 
			
		||||
  resources,
 | 
			
		||||
  lng: "en",
 | 
			
		||||
  fallbackLng: "en",
 | 
			
		||||
  interpolation: {
 | 
			
		||||
    escapeValue: false,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const PROTOCOL = "hydralauncher";
 | 
			
		||||
 | 
			
		||||
if (process.defaultApp) {
 | 
			
		||||
  if (process.argv.length >= 2) {
 | 
			
		||||
    app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
 | 
			
		||||
      path.resolve(process.argv[1]),
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
} else {
 | 
			
		||||
  app.setAsDefaultProtocolClient(PROTOCOL);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// This method will be called when Electron has finished
 | 
			
		||||
// initialization and is ready to create browser windows.
 | 
			
		||||
// Some APIs can only be used after this event occurs.
 | 
			
		||||
app.whenReady().then(() => {
 | 
			
		||||
  dataSource.initialize().then(async () => {
 | 
			
		||||
    // await resolveDatabaseUpdates();
 | 
			
		||||
 | 
			
		||||
    await import("./main");
 | 
			
		||||
 | 
			
		||||
    const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
      where: { id: 1 },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    WindowManager.createMainWindow();
 | 
			
		||||
    // WindowManager.createSystemTray(userPreferences?.language || "en");
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on("second-instance", (_event, commandLine) => {
 | 
			
		||||
  // Someone tried to run a second instance, we should focus our window.
 | 
			
		||||
  if (WindowManager.mainWindow) {
 | 
			
		||||
    if (WindowManager.mainWindow.isMinimized())
 | 
			
		||||
      WindowManager.mainWindow.restore();
 | 
			
		||||
 | 
			
		||||
    WindowManager.mainWindow.focus();
 | 
			
		||||
  } else {
 | 
			
		||||
    WindowManager.createMainWindow();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const [, path] = commandLine.pop().split("://");
 | 
			
		||||
  if (path) WindowManager.redirect(path);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on("open-url", (_event, url) => {
 | 
			
		||||
  const [, path] = url.split("://");
 | 
			
		||||
  WindowManager.redirect(path);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Quit when all windows are closed, except on macOS. There, it's common
 | 
			
		||||
// for applications and their menu bar to stay active until the user quits
 | 
			
		||||
// explicitly with Cmd + Q.
 | 
			
		||||
app.on("window-all-closed", () => {
 | 
			
		||||
  WindowManager.mainWindow = null;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on("activate", () => {
 | 
			
		||||
  // On OS X it's common to re-create a window in the app when the
 | 
			
		||||
  // dock icon is clicked and there are no other windows open.
 | 
			
		||||
  if (BrowserWindow.getAllWindows().length === 0) {
 | 
			
		||||
    WindowManager.createMainWindow();
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// In this file you can include the rest of your app's specific main process
 | 
			
		||||
// code. You can also put them in separate files and import them here.
 | 
			
		||||
							
								
								
									
										129
									
								
								src/main/main.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/main/main.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,129 @@
 | 
			
		|||
import { stateManager } from "./state-manager";
 | 
			
		||||
import { GameStatus, repackers } from "./constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNewGOGGames,
 | 
			
		||||
  getNewRepacksFromCPG,
 | 
			
		||||
  getNewRepacksFromUser,
 | 
			
		||||
  getNewRepacksFromXatab,
 | 
			
		||||
  // getNewRepacksFromOnlineFix,
 | 
			
		||||
  readPipe,
 | 
			
		||||
  startProcessWatcher,
 | 
			
		||||
  writePipe,
 | 
			
		||||
} from "./services";
 | 
			
		||||
import {
 | 
			
		||||
  gameRepository,
 | 
			
		||||
  repackRepository,
 | 
			
		||||
  repackerFriendlyNameRepository,
 | 
			
		||||
  steamGameRepository,
 | 
			
		||||
  userPreferencesRepository,
 | 
			
		||||
} from "./repository";
 | 
			
		||||
import { TorrentClient } from "./services/torrent-client";
 | 
			
		||||
import { Repack } from "./entity";
 | 
			
		||||
import { Notification } from "electron";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
startProcessWatcher();
 | 
			
		||||
 | 
			
		||||
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
 | 
			
		||||
 | 
			
		||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
    relations: { repack: true },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (game) {
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: game.id,
 | 
			
		||||
      magnet: game.repack.magnet,
 | 
			
		||||
      save_path: game.downloadPath,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  readPipe.socket.on("data", (data) => {
 | 
			
		||||
    TorrentClient.onSocketData(data);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const track1337xUsers = async (existingRepacks: Repack[]) => {
 | 
			
		||||
  for (const repacker of repackers) {
 | 
			
		||||
    await getNewRepacksFromUser(
 | 
			
		||||
      repacker,
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === repacker)
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const checkForNewRepacks = async () => {
 | 
			
		||||
  const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
    where: { id: 1 },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const existingRepacks = stateManager.getValue("repacks");
 | 
			
		||||
 | 
			
		||||
  Promise.allSettled([
 | 
			
		||||
    getNewGOGGames(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "GOG")
 | 
			
		||||
    ),
 | 
			
		||||
    getNewRepacksFromXatab(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "Xatab")
 | 
			
		||||
    ),
 | 
			
		||||
    getNewRepacksFromCPG(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "CPG")
 | 
			
		||||
    ),
 | 
			
		||||
    // getNewRepacksFromOnlineFix(
 | 
			
		||||
    //   existingRepacks.filter((repack) => repack.repacker === "onlinefix")
 | 
			
		||||
    // ),
 | 
			
		||||
    track1337xUsers(existingRepacks),
 | 
			
		||||
  ]).then(() => {
 | 
			
		||||
    repackRepository.count().then((count) => {
 | 
			
		||||
      const total = count - stateManager.getValue("repacks").length;
 | 
			
		||||
 | 
			
		||||
      if (total > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
 | 
			
		||||
        new Notification({
 | 
			
		||||
          title: t("repack_list_updated", {
 | 
			
		||||
            ns: "notifications",
 | 
			
		||||
            lng: userPreferences?.language || "en",
 | 
			
		||||
          }),
 | 
			
		||||
          body: t("repack_count", {
 | 
			
		||||
            ns: "notifications",
 | 
			
		||||
            lng: userPreferences?.language || "en",
 | 
			
		||||
            count: total,
 | 
			
		||||
          }),
 | 
			
		||||
        }).show();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const loadState = async () => {
 | 
			
		||||
  const [friendlyNames, repacks, steamGames] = await Promise.all([
 | 
			
		||||
    repackerFriendlyNameRepository.find(),
 | 
			
		||||
    repackRepository.find({
 | 
			
		||||
      order: {
 | 
			
		||||
        createdAt: "desc",
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    steamGameRepository.find({
 | 
			
		||||
      order: {
 | 
			
		||||
        name: "asc",
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  stateManager.setValue("repackersFriendlyNames", friendlyNames);
 | 
			
		||||
  stateManager.setValue("repacks", repacks);
 | 
			
		||||
  stateManager.setValue("steamGames", steamGames);
 | 
			
		||||
 | 
			
		||||
  import("./events");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
loadState().then(() => checkForNewRepacks());
 | 
			
		||||
							
								
								
									
										30
									
								
								src/main/repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/main/repository.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
import { dataSource } from "./data-source";
 | 
			
		||||
import {
 | 
			
		||||
  Game,
 | 
			
		||||
  GameShopCache,
 | 
			
		||||
  ImageCache,
 | 
			
		||||
  Repack,
 | 
			
		||||
  RepackerFriendlyName,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
  MigrationScript,
 | 
			
		||||
  SteamGame,
 | 
			
		||||
} from "@main/entity";
 | 
			
		||||
 | 
			
		||||
export const gameRepository = dataSource.getRepository(Game);
 | 
			
		||||
 | 
			
		||||
export const imageCacheRepository = dataSource.getRepository(ImageCache);
 | 
			
		||||
 | 
			
		||||
export const repackRepository = dataSource.getRepository(Repack);
 | 
			
		||||
 | 
			
		||||
export const repackerFriendlyNameRepository =
 | 
			
		||||
  dataSource.getRepository(RepackerFriendlyName);
 | 
			
		||||
 | 
			
		||||
export const userPreferencesRepository =
 | 
			
		||||
  dataSource.getRepository(UserPreferences);
 | 
			
		||||
 | 
			
		||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
 | 
			
		||||
 | 
			
		||||
export const migrationScriptRepository =
 | 
			
		||||
  dataSource.getRepository(MigrationScript);
 | 
			
		||||
 | 
			
		||||
export const steamGameRepository = dataSource.getRepository(SteamGame);
 | 
			
		||||
							
								
								
									
										38
									
								
								src/main/services/fifo.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/main/services/fifo.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import net from "node:net";
 | 
			
		||||
import crypto from "node:crypto";
 | 
			
		||||
import os from "node:os";
 | 
			
		||||
 | 
			
		||||
export class FIFO {
 | 
			
		||||
  public socket: null | net.Socket = null;
 | 
			
		||||
  public socketPath = this.generateSocketFilename();
 | 
			
		||||
 | 
			
		||||
  private generateSocketFilename() {
 | 
			
		||||
    const hash = crypto.randomBytes(16).toString("hex");
 | 
			
		||||
 | 
			
		||||
    if (process.platform === "win32") {
 | 
			
		||||
      return "\\\\.\\pipe\\" + hash;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return path.join(os.tmpdir(), hash);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public write(data: any) {
 | 
			
		||||
    if (!this.socket) return;
 | 
			
		||||
    this.socket.write(Buffer.from(JSON.stringify(data)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public createPipe() {
 | 
			
		||||
    return new Promise((resolve) => {
 | 
			
		||||
      const server = net.createServer((socket) => {
 | 
			
		||||
        this.socket = socket;
 | 
			
		||||
        resolve(null);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      server.listen(this.socketPath);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const writePipe = new FIFO();
 | 
			
		||||
export const readPipe = new FIFO();
 | 
			
		||||
							
								
								
									
										60
									
								
								src/main/services/how-long-to-beat.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/main/services/how-long-to-beat.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,60 @@
 | 
			
		|||
import { formatName } from "@main/helpers";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
import { JSDOM } from "jsdom";
 | 
			
		||||
import { requestWebPage } from "./repack-tracker/helpers";
 | 
			
		||||
import { HowLongToBeatCategory } from "@types";
 | 
			
		||||
 | 
			
		||||
export interface HowLongToBeatResult {
 | 
			
		||||
  game_id: number;
 | 
			
		||||
  profile_steam: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface HowLongToBeatSearchResponse {
 | 
			
		||||
  data: HowLongToBeatResult[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const searchHowLongToBeat = async (gameName: string) => {
 | 
			
		||||
  const response = await axios.post(
 | 
			
		||||
    "https://howlongtobeat.com/api/search",
 | 
			
		||||
    {
 | 
			
		||||
      searchType: "games",
 | 
			
		||||
      searchTerms: formatName(gameName).split(" "),
 | 
			
		||||
      searchPage: 1,
 | 
			
		||||
      size: 100,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      headers: {
 | 
			
		||||
        "User-Agent":
 | 
			
		||||
          "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
 | 
			
		||||
        Referer: "https://howlongtobeat.com/",
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return response.data as HowLongToBeatSearchResponse;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getHowLongToBeatGame = async (
 | 
			
		||||
  id: string
 | 
			
		||||
): Promise<HowLongToBeatCategory[]> => {
 | 
			
		||||
  const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
  const { document } = window;
 | 
			
		||||
 | 
			
		||||
  const $ul = document.querySelector(".shadow_shadow ul");
 | 
			
		||||
  const $lis = Array.from($ul.children);
 | 
			
		||||
 | 
			
		||||
  return $lis.map(($li) => {
 | 
			
		||||
    const title = $li.querySelector("h4").textContent;
 | 
			
		||||
    const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
 | 
			
		||||
 | 
			
		||||
    const accuracy = accuracyClassName.split("time_").at(1);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      title,
 | 
			
		||||
      duration: $li.querySelector("h5").textContent,
 | 
			
		||||
      accuracy,
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/services/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/services/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
export * from "./logger";
 | 
			
		||||
export * from "./repack-tracker";
 | 
			
		||||
export * from "./steam";
 | 
			
		||||
export * from "./steam-250";
 | 
			
		||||
export * from "./steam-grid";
 | 
			
		||||
export * from "./update-resolver";
 | 
			
		||||
export * from "./window-manager";
 | 
			
		||||
export * from "./fifo";
 | 
			
		||||
export * from "./torrent-client";
 | 
			
		||||
export * from "./how-long-to-beat";
 | 
			
		||||
export * from "./process-watcher";
 | 
			
		||||
							
								
								
									
										11
									
								
								src/main/services/logger.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/main/services/logger.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
import winston from "winston";
 | 
			
		||||
 | 
			
		||||
export const logger = winston.createLogger({
 | 
			
		||||
  level: "info",
 | 
			
		||||
  format: winston.format.json(),
 | 
			
		||||
  transports: [
 | 
			
		||||
    new winston.transports.File({ filename: "error.log", level: "error" }),
 | 
			
		||||
    new winston.transports.File({ filename: "info.log", level: "info" }),
 | 
			
		||||
    new winston.transports.File({ filename: "combined.log" }),
 | 
			
		||||
  ],
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										80
									
								
								src/main/services/process-watcher.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/main/services/process-watcher.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
import { IsNull, Not } from "typeorm";
 | 
			
		||||
 | 
			
		||||
import { gameRepository } from "@main/repository";
 | 
			
		||||
import { getProcesses } from "@main/helpers";
 | 
			
		||||
import { WindowManager } from "./window-manager";
 | 
			
		||||
 | 
			
		||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 | 
			
		||||
 | 
			
		||||
export const startProcessWatcher = async () => {
 | 
			
		||||
  const sleepTime = 100;
 | 
			
		||||
  const gamesPlaytime = new Map<number, number>();
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line no-constant-condition
 | 
			
		||||
  while (true) {
 | 
			
		||||
    const games = await gameRepository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        executablePath: Not(IsNull()),
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const processes = await getProcesses();
 | 
			
		||||
 | 
			
		||||
    for (const game of games) {
 | 
			
		||||
      const gameProcess = processes.find((runningProcess) => {
 | 
			
		||||
        const basename = path.win32.basename(game.executablePath);
 | 
			
		||||
        const basenameWithoutExtension = path.win32.basename(
 | 
			
		||||
          game.executablePath,
 | 
			
		||||
          path.extname(game.executablePath)
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (process.platform === "win32") {
 | 
			
		||||
          return runningProcess.name === basename;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return [basename, basenameWithoutExtension].includes(
 | 
			
		||||
          runningProcess.name
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (gameProcess) {
 | 
			
		||||
        if (gamesPlaytime.has(game.id)) {
 | 
			
		||||
          const zero = gamesPlaytime.get(game.id);
 | 
			
		||||
          const delta = performance.now() - zero;
 | 
			
		||||
 | 
			
		||||
          if (WindowManager.mainWindow) {
 | 
			
		||||
            WindowManager.mainWindow.webContents.send("on-playtime", game.id);
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          await gameRepository.update(game.id, {
 | 
			
		||||
            playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          gameRepository.update(game.id, {
 | 
			
		||||
            lastTimePlayed: new Date().toUTCString(),
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          gamesPlaytime.set(game.id, performance.now());
 | 
			
		||||
          await sleep(sleepTime);
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        gamesPlaytime.set(game.id, performance.now());
 | 
			
		||||
 | 
			
		||||
        await sleep(sleepTime);
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (gamesPlaytime.has(game.id)) {
 | 
			
		||||
        gamesPlaytime.delete(game.id);
 | 
			
		||||
        if (WindowManager.mainWindow) {
 | 
			
		||||
          WindowManager.mainWindow.webContents.send("on-game-close", game.id);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await sleep(sleepTime);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										135
									
								
								src/main/services/repack-tracker/1337x.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								src/main/services/repack-tracker/1337x.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,135 @@
 | 
			
		|||
import { JSDOM } from "jsdom";
 | 
			
		||||
 | 
			
		||||
import { formatUploadDate } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
import { Repack } from "@main/entity";
 | 
			
		||||
import { requestWebPage, savePage } from "./helpers";
 | 
			
		||||
import type { GameRepackInput } from "./helpers";
 | 
			
		||||
 | 
			
		||||
export const request1337x = async (path: string) =>
 | 
			
		||||
  requestWebPage(`https://1337xx.to${path}`);
 | 
			
		||||
 | 
			
		||||
/* TODO: $a will often be null */
 | 
			
		||||
const getTorrentDetails = async (path: string) => {
 | 
			
		||||
  const response = await request1337x(path);
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
  const { document } = window;
 | 
			
		||||
 | 
			
		||||
  const $a = window.document.querySelector(
 | 
			
		||||
    ".torrentdown1"
 | 
			
		||||
  ) as HTMLAnchorElement;
 | 
			
		||||
 | 
			
		||||
  const $ul = Array.from(
 | 
			
		||||
    document.querySelectorAll(".torrent-detail-page .list")
 | 
			
		||||
  );
 | 
			
		||||
  const [$firstColumn, $secondColumn] = $ul;
 | 
			
		||||
 | 
			
		||||
  if (!$firstColumn || !$secondColumn) {
 | 
			
		||||
    return { magnet: $a?.href };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
 | 
			
		||||
  const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    magnet: $a?.href,
 | 
			
		||||
    fileSize: $totalSize.querySelector("span").textContent ?? undefined,
 | 
			
		||||
    uploadDate: formatUploadDate(
 | 
			
		||||
      $dateUploaded.querySelector("span").textContent!
 | 
			
		||||
    ),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getTorrentListLastPage = async (user: string) => {
 | 
			
		||||
  const response = await request1337x(`/user/${user}/1`);
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
 | 
			
		||||
  const $ul = window.document.querySelector(".pagination > ul");
 | 
			
		||||
 | 
			
		||||
  if ($ul) {
 | 
			
		||||
    const $li = Array.from($ul.querySelectorAll("li")).at(-1);
 | 
			
		||||
    const text = $li?.textContent;
 | 
			
		||||
 | 
			
		||||
    if (text === ">>") {
 | 
			
		||||
      const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
 | 
			
		||||
      return Number($previousLi?.textContent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Number(text);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return -1;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const extractTorrentsFromDocument = async (
 | 
			
		||||
  page: number,
 | 
			
		||||
  user: string,
 | 
			
		||||
  document: Document,
 | 
			
		||||
  existingRepacks: Repack[] = []
 | 
			
		||||
): Promise<GameRepackInput[]> => {
 | 
			
		||||
  const $trs = Array.from(document.querySelectorAll("tbody tr"));
 | 
			
		||||
 | 
			
		||||
  return Promise.all(
 | 
			
		||||
    $trs.map(async ($tr) => {
 | 
			
		||||
      const $td = $tr.querySelector("td");
 | 
			
		||||
 | 
			
		||||
      const [, $name] = Array.from($td!.querySelectorAll("a"));
 | 
			
		||||
      const url = $name.href;
 | 
			
		||||
      const title = $name.textContent ?? "";
 | 
			
		||||
 | 
			
		||||
      if (existingRepacks.some((repack) => repack.title === title)) {
 | 
			
		||||
        return {
 | 
			
		||||
          title,
 | 
			
		||||
          magnet: "",
 | 
			
		||||
          fileSize: null,
 | 
			
		||||
          uploadDate: null,
 | 
			
		||||
          repacker: user,
 | 
			
		||||
          page,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const details = await getTorrentDetails(url);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        title,
 | 
			
		||||
        magnet: details.magnet,
 | 
			
		||||
        fileSize: details.fileSize ?? null,
 | 
			
		||||
        uploadDate: details.uploadDate ?? null,
 | 
			
		||||
        repacker: user,
 | 
			
		||||
        page,
 | 
			
		||||
      };
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getNewRepacksFromUser = async (
 | 
			
		||||
  user: string,
 | 
			
		||||
  existingRepacks: Repack[],
 | 
			
		||||
  page = 1
 | 
			
		||||
): Promise<Repack[]> => {
 | 
			
		||||
  const response = await request1337x(`/user/${user}/${page}`);
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
 | 
			
		||||
  const repacks = await extractTorrentsFromDocument(
 | 
			
		||||
    page,
 | 
			
		||||
    user,
 | 
			
		||||
    window.document,
 | 
			
		||||
    existingRepacks
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const newRepacks = repacks.filter(
 | 
			
		||||
    (repack) =>
 | 
			
		||||
      repack.uploadDate &&
 | 
			
		||||
      !existingRepacks.some(
 | 
			
		||||
        (existingRepack) => existingRepack.title === repack.title
 | 
			
		||||
      )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!newRepacks.length) return;
 | 
			
		||||
 | 
			
		||||
  await savePage(newRepacks);
 | 
			
		||||
 | 
			
		||||
  return getNewRepacksFromUser(user, existingRepacks, page + 1);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										65
									
								
								src/main/services/repack-tracker/cpg-repacks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/main/services/repack-tracker/cpg-repacks.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
import { JSDOM } from "jsdom";
 | 
			
		||||
 | 
			
		||||
import { Repack } from "@main/entity";
 | 
			
		||||
 | 
			
		||||
import { requestWebPage, savePage } from "./helpers";
 | 
			
		||||
import type { GameRepackInput } from "./helpers";
 | 
			
		||||
import { logger } from "../logger";
 | 
			
		||||
 | 
			
		||||
export const getNewRepacksFromCPG = async (
 | 
			
		||||
  existingRepacks: Repack[] = [],
 | 
			
		||||
  page = 1
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
  const data = await requestWebPage(`https://cpgrepacks.site/page/${page}`);
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(data);
 | 
			
		||||
 | 
			
		||||
  const repacks: GameRepackInput[] = [];
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
 | 
			
		||||
      const $title = $post.querySelector(".entry-title");
 | 
			
		||||
      const uploadDate = $post.querySelector("time").getAttribute("datetime");
 | 
			
		||||
 | 
			
		||||
      const $downloadInfo = Array.from(
 | 
			
		||||
        $post.querySelectorAll(".wp-block-heading")
 | 
			
		||||
      ).find(($heading) => $heading.textContent.startsWith("Download"));
 | 
			
		||||
 | 
			
		||||
      /* Side note: CPG often misspells "Magnet" as "Magent" */
 | 
			
		||||
      const $magnet = Array.from($post.querySelectorAll("a")).find(
 | 
			
		||||
        ($a) =>
 | 
			
		||||
          $a.textContent.startsWith("Magnet") ||
 | 
			
		||||
          $a.textContent.startsWith("Magent")
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const fileSize = $downloadInfo.textContent
 | 
			
		||||
        .split("Download link => ")
 | 
			
		||||
        .at(1);
 | 
			
		||||
 | 
			
		||||
      repacks.push({
 | 
			
		||||
        title: $title.textContent,
 | 
			
		||||
        fileSize: fileSize ?? "N/A",
 | 
			
		||||
        magnet: $magnet.href,
 | 
			
		||||
        repacker: "CPG",
 | 
			
		||||
        page,
 | 
			
		||||
        uploadDate: new Date(uploadDate),
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    logger.error(err.message, { method: "getNewRepacksFromCPG" });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const newRepacks = repacks.filter(
 | 
			
		||||
    (repack) =>
 | 
			
		||||
      repack.uploadDate &&
 | 
			
		||||
      !existingRepacks.some(
 | 
			
		||||
        (existingRepack) => existingRepack.title === repack.title
 | 
			
		||||
      )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!newRepacks.length) return;
 | 
			
		||||
 | 
			
		||||
  await savePage(newRepacks);
 | 
			
		||||
 | 
			
		||||
  return getNewRepacksFromCPG(existingRepacks, page + 1);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										78
									
								
								src/main/services/repack-tracker/gog.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/main/services/repack-tracker/gog.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,78 @@
 | 
			
		|||
import { JSDOM, VirtualConsole } from "jsdom";
 | 
			
		||||
import { GameRepackInput, requestWebPage, savePage } from "./helpers";
 | 
			
		||||
import { Repack } from "@main/entity";
 | 
			
		||||
import { logger } from "../logger";
 | 
			
		||||
 | 
			
		||||
const virtualConsole = new VirtualConsole();
 | 
			
		||||
 | 
			
		||||
const getGOGGame = async (url: string) => {
 | 
			
		||||
  const data = await requestWebPage(url);
 | 
			
		||||
  const { window } = new JSDOM(data, { virtualConsole });
 | 
			
		||||
 | 
			
		||||
  const $modifiedTime = window.document.querySelector(
 | 
			
		||||
    '[property="article:modified_time"]'
 | 
			
		||||
  ) as HTMLMetaElement;
 | 
			
		||||
 | 
			
		||||
  const $em = window.document.querySelector(
 | 
			
		||||
    "p:not(.lightweight-accordion *) em"
 | 
			
		||||
  );
 | 
			
		||||
  const fileSize = $em.textContent.split("Size: ").at(1);
 | 
			
		||||
  const $downloadButton = window.document.querySelector(
 | 
			
		||||
    ".download-btn:not(.lightweight-accordion *)"
 | 
			
		||||
  ) as HTMLAnchorElement;
 | 
			
		||||
 | 
			
		||||
  const { searchParams } = new URL($downloadButton.href);
 | 
			
		||||
  const magnet = Buffer.from(searchParams.get("url"), "base64").toString(
 | 
			
		||||
    "utf-8"
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    fileSize: fileSize ?? "N/A",
 | 
			
		||||
    uploadDate: new Date($modifiedTime.content),
 | 
			
		||||
    repacker: "GOG",
 | 
			
		||||
    magnet,
 | 
			
		||||
    page: 1,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const data = await requestWebPage(
 | 
			
		||||
      "https://freegogpcgames.com/a-z-games-list/"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const { window } = new JSDOM(data, { virtualConsole });
 | 
			
		||||
 | 
			
		||||
    const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
 | 
			
		||||
 | 
			
		||||
    for (const $ul of $uls) {
 | 
			
		||||
      const repacks: GameRepackInput[] = [];
 | 
			
		||||
      const $lis = Array.from($ul.querySelectorAll("li"));
 | 
			
		||||
 | 
			
		||||
      for (const $li of $lis) {
 | 
			
		||||
        const $a = $li.querySelector("a");
 | 
			
		||||
        const href = $a.href;
 | 
			
		||||
 | 
			
		||||
        const title = $a.textContent.trim();
 | 
			
		||||
 | 
			
		||||
        const gameExists = existingRepacks.some(
 | 
			
		||||
          (existingRepack) => existingRepack.title === title
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!gameExists) {
 | 
			
		||||
          try {
 | 
			
		||||
            const game = await getGOGGame(href);
 | 
			
		||||
 | 
			
		||||
            repacks.push({ ...game, title });
 | 
			
		||||
          } catch (err) {
 | 
			
		||||
            logger.error(err.message, { method: "getGOGGame", url: href });
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (repacks.length) await savePage(repacks);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    logger.error(err.message, { method: "getNewGOGGames" });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										18
									
								
								src/main/services/repack-tracker/helpers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/main/services/repack-tracker/helpers.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import { repackRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import type { GameRepack } from "@types";
 | 
			
		||||
 | 
			
		||||
export type GameRepackInput = Omit<
 | 
			
		||||
  GameRepack,
 | 
			
		||||
  "id" | "repackerFriendlyName" | "createdAt" | "updatedAt"
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export const savePage = async (repacks: GameRepackInput[]) =>
 | 
			
		||||
  Promise.all(
 | 
			
		||||
    repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const requestWebPage = async (url: string) =>
 | 
			
		||||
  fetch(url, {
 | 
			
		||||
    method: "GET",
 | 
			
		||||
  }).then((response) => response.text());
 | 
			
		||||
							
								
								
									
										5
									
								
								src/main/services/repack-tracker/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/main/services/repack-tracker/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
export * from "./1337x";
 | 
			
		||||
export * from "./xatab";
 | 
			
		||||
export * from "./cpg-repacks";
 | 
			
		||||
export * from "./gog";
 | 
			
		||||
// export * from "./online-fix";
 | 
			
		||||
							
								
								
									
										207
									
								
								src/main/services/repack-tracker/online-fix.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								src/main/services/repack-tracker/online-fix.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,207 @@
 | 
			
		|||
import { Repack } from "@main/entity";
 | 
			
		||||
import { savePage } from "./helpers";
 | 
			
		||||
import type { GameRepackInput } from "./helpers";
 | 
			
		||||
import { logger } from "../logger";
 | 
			
		||||
import parseTorrent, {
 | 
			
		||||
  toMagnetURI,
 | 
			
		||||
  Instance as TorrentInstance,
 | 
			
		||||
} from "parse-torrent";
 | 
			
		||||
import { JSDOM } from "jsdom";
 | 
			
		||||
import { gotScraping } from "got-scraping";
 | 
			
		||||
import { CookieJar } from "tough-cookie";
 | 
			
		||||
 | 
			
		||||
import { format, parse, sub } from "date-fns";
 | 
			
		||||
import { ru } from "date-fns/locale";
 | 
			
		||||
import { decode } from "windows-1251";
 | 
			
		||||
import { onlinefixFormatter } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
export const getNewRepacksFromOnlineFix = async (
 | 
			
		||||
  existingRepacks: Repack[] = [],
 | 
			
		||||
  page = 1,
 | 
			
		||||
  cookieJar = new CookieJar()
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
  const hasCredentials =
 | 
			
		||||
    import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME &&
 | 
			
		||||
    import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD;
 | 
			
		||||
  if (!hasCredentials) return;
 | 
			
		||||
 | 
			
		||||
  const http = gotScraping.extend({
 | 
			
		||||
    headerGeneratorOptions: {
 | 
			
		||||
      browsers: [
 | 
			
		||||
        {
 | 
			
		||||
          name: "chrome",
 | 
			
		||||
          minVersion: 87,
 | 
			
		||||
          maxVersion: 89,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      devices: ["desktop"],
 | 
			
		||||
      locales: ["en-US"],
 | 
			
		||||
      operatingSystems: ["windows", "linux"],
 | 
			
		||||
    },
 | 
			
		||||
    cookieJar: cookieJar,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (page === 1) {
 | 
			
		||||
    await http.get("https://online-fix.me/");
 | 
			
		||||
    const preLogin =
 | 
			
		||||
      ((await http
 | 
			
		||||
        .get("https://online-fix.me/engine/ajax/authtoken.php", {
 | 
			
		||||
          headers: {
 | 
			
		||||
            "X-Requested-With": "XMLHttpRequest",
 | 
			
		||||
            Referer: "https://online-fix.me/",
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
        .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
 | 
			
		||||
      .post("https://online-fix.me/", {
 | 
			
		||||
        encoding: "binary",
 | 
			
		||||
        headers: {
 | 
			
		||||
          Referer: "https://online-fix.me",
 | 
			
		||||
          Origin: "https://online-fix.me",
 | 
			
		||||
          "Content-Type": "application/x-www-form-urlencoded",
 | 
			
		||||
        },
 | 
			
		||||
        body: params.toString(),
 | 
			
		||||
      })
 | 
			
		||||
      .text();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const pageParams = page > 1 ? `${`/page/${page}`}` : "";
 | 
			
		||||
 | 
			
		||||
  const home = await http.get(`https://online-fix.me${pageParams}`, {
 | 
			
		||||
    encoding: "binary",
 | 
			
		||||
  });
 | 
			
		||||
  const document = new JSDOM(home.body).window.document;
 | 
			
		||||
 | 
			
		||||
  const repacks: GameRepackInput[] = [];
 | 
			
		||||
  const articles = Array.from(document.querySelectorAll(".news"));
 | 
			
		||||
  const totalPages = Number(
 | 
			
		||||
    document.querySelector("nav > a:nth-child(13)").textContent
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
      articles.map(async (article) => {
 | 
			
		||||
        const gameName = onlinefixFormatter(
 | 
			
		||||
          decode(article.querySelector("h2.title")?.textContent?.trim())
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const gameLink = article.querySelector("a")?.getAttribute("href");
 | 
			
		||||
 | 
			
		||||
        if (!gameLink) return;
 | 
			
		||||
 | 
			
		||||
        const gamePage = await http
 | 
			
		||||
          .get(gameLink, {
 | 
			
		||||
            encoding: "binary",
 | 
			
		||||
          })
 | 
			
		||||
          .text();
 | 
			
		||||
 | 
			
		||||
        const gameDocument = new JSDOM(gamePage).window.document;
 | 
			
		||||
 | 
			
		||||
        const uploadDateText = gameDocument.querySelector("time").textContent;
 | 
			
		||||
 | 
			
		||||
        let decodedDateText = decode(uploadDateText);
 | 
			
		||||
 | 
			
		||||
        // "Вчера" means yesterday.
 | 
			
		||||
        if (decodedDateText.includes("Вчера")) {
 | 
			
		||||
          const yesterday = sub(new Date(), { days: 1 });
 | 
			
		||||
          const formattedYesterday = format(yesterday, "d LLLL yyyy", {
 | 
			
		||||
            locale: ru,
 | 
			
		||||
          });
 | 
			
		||||
          decodedDateText = decodedDateText.replace(
 | 
			
		||||
            "Вчера", // "Change yesterday to the default expected date format"
 | 
			
		||||
            formattedYesterday
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const uploadDate = parse(
 | 
			
		||||
          decodedDateText,
 | 
			
		||||
          "d LLLL yyyy, HH:mm",
 | 
			
		||||
          new Date(),
 | 
			
		||||
          {
 | 
			
		||||
            locale: ru,
 | 
			
		||||
          }
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
          .get(torrentPrePage, {
 | 
			
		||||
            encoding: "binary",
 | 
			
		||||
            headers: {
 | 
			
		||||
              Referer: gameLink,
 | 
			
		||||
            },
 | 
			
		||||
          })
 | 
			
		||||
          .text();
 | 
			
		||||
 | 
			
		||||
        const torrentDocument = new JSDOM(torrentPage).window.document;
 | 
			
		||||
 | 
			
		||||
        const torrentLink = torrentDocument
 | 
			
		||||
          .querySelector("a:nth-child(2)")
 | 
			
		||||
          ?.getAttribute("href");
 | 
			
		||||
 | 
			
		||||
        const torrentFile = Buffer.from(
 | 
			
		||||
          await http
 | 
			
		||||
            .get(`${torrentPrePage}/${torrentLink}`, {
 | 
			
		||||
              responseType: "buffer",
 | 
			
		||||
            })
 | 
			
		||||
            .buffer()
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const torrent = parseTorrent(torrentFile) as TorrentInstance;
 | 
			
		||||
        const magnetLink = toMagnetURI({
 | 
			
		||||
          infoHash: torrent.infoHash,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const torrentSizeInBytes = torrent.length;
 | 
			
		||||
        const fileSizeFormatted =
 | 
			
		||||
          torrentSizeInBytes >= 1024 ** 3
 | 
			
		||||
            ? `${(torrentSizeInBytes / 1024 ** 3).toFixed(1)}GBs`
 | 
			
		||||
            : `${(torrentSizeInBytes / 1024 ** 2).toFixed(1)}MBs`;
 | 
			
		||||
 | 
			
		||||
        repacks.push({
 | 
			
		||||
          fileSize: fileSizeFormatted,
 | 
			
		||||
          magnet: magnetLink,
 | 
			
		||||
          page: 1,
 | 
			
		||||
          repacker: "onlinefix",
 | 
			
		||||
          title: gameName,
 | 
			
		||||
          uploadDate: uploadDate,
 | 
			
		||||
        });
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    logger.error(err.message, { method: "getNewRepacksFromOnlineFix" });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const newRepacks = repacks.filter(
 | 
			
		||||
    (repack) =>
 | 
			
		||||
      repack.uploadDate &&
 | 
			
		||||
      !existingRepacks.some(
 | 
			
		||||
        (existingRepack) => existingRepack.title === repack.title
 | 
			
		||||
      )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!newRepacks.length) return;
 | 
			
		||||
  if (page === totalPages) return;
 | 
			
		||||
 | 
			
		||||
  await savePage(newRepacks);
 | 
			
		||||
 | 
			
		||||
  return getNewRepacksFromOnlineFix(existingRepacks, page + 1, cookieJar);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										95
									
								
								src/main/services/repack-tracker/xatab.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/main/services/repack-tracker/xatab.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
import { JSDOM } from "jsdom";
 | 
			
		||||
 | 
			
		||||
import parseTorrent, { toMagnetURI } from "parse-torrent";
 | 
			
		||||
 | 
			
		||||
import { Repack } from "@main/entity";
 | 
			
		||||
import { logger } from "../logger";
 | 
			
		||||
import { requestWebPage, savePage } from "./helpers";
 | 
			
		||||
import type { GameRepackInput } from "./helpers";
 | 
			
		||||
 | 
			
		||||
const getTorrentBuffer = (url: string) =>
 | 
			
		||||
  fetch(url, { method: "GET" }).then((response) =>
 | 
			
		||||
    response.arrayBuffer().then((buffer) => Buffer.from(buffer))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
const formatXatabDate = (str: string) => {
 | 
			
		||||
  const date = new Date();
 | 
			
		||||
 | 
			
		||||
  const [day, month, year] = str.split(".");
 | 
			
		||||
 | 
			
		||||
  date.setDate(Number(day));
 | 
			
		||||
  date.setMonth(Number(month) - 1);
 | 
			
		||||
  date.setFullYear(Number(year));
 | 
			
		||||
  date.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
  return date;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const formatXatabDownloadSize = (str: string) =>
 | 
			
		||||
  str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
 | 
			
		||||
 | 
			
		||||
const getXatabRepack = async (url: string) => {
 | 
			
		||||
  const data = await requestWebPage(url);
 | 
			
		||||
  const { window } = new JSDOM(data);
 | 
			
		||||
 | 
			
		||||
  const $uploadDate = window.document.querySelector(".entry__date");
 | 
			
		||||
  const $size = window.document.querySelector(".entry__info-size");
 | 
			
		||||
 | 
			
		||||
  const $downloadButton = window.document.querySelector(
 | 
			
		||||
    ".download-torrent"
 | 
			
		||||
  ) as HTMLAnchorElement;
 | 
			
		||||
 | 
			
		||||
  if (!$downloadButton) throw new Error("Download button not found");
 | 
			
		||||
 | 
			
		||||
  const torrentBuffer = await getTorrentBuffer($downloadButton.href);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
 | 
			
		||||
    magnet: toMagnetURI({
 | 
			
		||||
      infoHash: parseTorrent(torrentBuffer).infoHash,
 | 
			
		||||
    }),
 | 
			
		||||
    uploadDate: formatXatabDate($uploadDate.textContent),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getNewRepacksFromXatab = async (
 | 
			
		||||
  existingRepacks: Repack[] = [],
 | 
			
		||||
  page = 1
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
  const data = await requestWebPage(`https://byxatab.com/page/${page}`);
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(data);
 | 
			
		||||
 | 
			
		||||
  const repacks: GameRepackInput[] = [];
 | 
			
		||||
 | 
			
		||||
  for (const $a of Array.from(
 | 
			
		||||
    window.document.querySelectorAll(".entry__title a")
 | 
			
		||||
  )) {
 | 
			
		||||
    try {
 | 
			
		||||
      const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
 | 
			
		||||
 | 
			
		||||
      repacks.push({
 | 
			
		||||
        title: $a.textContent,
 | 
			
		||||
        repacker: "Xatab",
 | 
			
		||||
        ...repack,
 | 
			
		||||
        page,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logger.error(err.message, { method: "getNewRepacksFromXatab" });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const newRepacks = repacks.filter(
 | 
			
		||||
    (repack) =>
 | 
			
		||||
      repack.uploadDate &&
 | 
			
		||||
      !existingRepacks.some(
 | 
			
		||||
        (existingRepack) => existingRepack.title === repack.title
 | 
			
		||||
      )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!newRepacks.length) return;
 | 
			
		||||
 | 
			
		||||
  await savePage(newRepacks);
 | 
			
		||||
 | 
			
		||||
  return getNewRepacksFromXatab(existingRepacks, page + 1);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										34
									
								
								src/main/services/steam-250.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/main/services/steam-250.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import axios from "axios";
 | 
			
		||||
import { JSDOM } from "jsdom";
 | 
			
		||||
import { shuffle } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
export const requestSteam250 = async (path: string) => {
 | 
			
		||||
  return axios.get(`https://steam250.com${path}`).then((response) => {
 | 
			
		||||
    const { window } = new JSDOM(response.data);
 | 
			
		||||
    const { document } = window;
 | 
			
		||||
 | 
			
		||||
    return Array.from(document.querySelectorAll(".appline .title a")).map(
 | 
			
		||||
      ($title: HTMLAnchorElement) => {
 | 
			
		||||
        const steamGameUrl = $title.href;
 | 
			
		||||
        if (!steamGameUrl) return null;
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          title: $title.textContent,
 | 
			
		||||
          objectID: steamGameUrl.split("/").pop(),
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const steam250Paths = [
 | 
			
		||||
  "/hidden_gems",
 | 
			
		||||
  `/${new Date().getFullYear()}`,
 | 
			
		||||
  "/top250",
 | 
			
		||||
  "/most_played",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const getRandomSteam250List = async () => {
 | 
			
		||||
  const [path] = shuffle(steam250Paths);
 | 
			
		||||
  return requestSteam250(path);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										71
									
								
								src/main/services/steam-grid.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/main/services/steam-grid.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import { getSteamAppAsset } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
export interface SteamGridResponse {
 | 
			
		||||
  success: boolean;
 | 
			
		||||
  data: {
 | 
			
		||||
    id: number;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SteamGridGameResponse {
 | 
			
		||||
  data: {
 | 
			
		||||
    platforms: {
 | 
			
		||||
      steam: {
 | 
			
		||||
        metadata: {
 | 
			
		||||
          clienticon: string;
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getSteamGridData = async (
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  path: string,
 | 
			
		||||
  shop: string,
 | 
			
		||||
  params: Record<string, string> = {}
 | 
			
		||||
): Promise<SteamGridResponse> => {
 | 
			
		||||
  const searchParams = new URLSearchParams(params);
 | 
			
		||||
 | 
			
		||||
  const response = await fetch(
 | 
			
		||||
    `https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
 | 
			
		||||
    {
 | 
			
		||||
      method: "GET",
 | 
			
		||||
      headers: {
 | 
			
		||||
        Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return response.json();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamGridGameById = async (
 | 
			
		||||
  id: number
 | 
			
		||||
): Promise<SteamGridGameResponse> => {
 | 
			
		||||
  const response = await fetch(
 | 
			
		||||
    `https://www.steamgriddb.com/api/public/game/${id}`,
 | 
			
		||||
    {
 | 
			
		||||
      method: "GET",
 | 
			
		||||
      headers: {
 | 
			
		||||
        Referer: "https://www.steamgriddb.com/",
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return response.json();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamGameIconUrl = async (objectID: string) => {
 | 
			
		||||
  const {
 | 
			
		||||
    data: { id: steamGridGameId },
 | 
			
		||||
  } = await getSteamGridData(objectID, "games", "steam");
 | 
			
		||||
 | 
			
		||||
  const steamGridGame = await getSteamGridGameById(steamGridGameId);
 | 
			
		||||
 | 
			
		||||
  return getSteamAppAsset(
 | 
			
		||||
    "icon",
 | 
			
		||||
    objectID,
 | 
			
		||||
    steamGridGame.data.platforms.steam.metadata.clienticon
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										35
									
								
								src/main/services/steam.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/main/services/steam.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
import axios from "axios";
 | 
			
		||||
 | 
			
		||||
import type { SteamAppDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import { logger } from "./logger";
 | 
			
		||||
 | 
			
		||||
export interface SteamAppDetailsResponse {
 | 
			
		||||
  [key: string]: {
 | 
			
		||||
    success: boolean;
 | 
			
		||||
    data: SteamAppDetails;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getSteamAppDetails = async (
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  language: string
 | 
			
		||||
) => {
 | 
			
		||||
  const searchParams = new URLSearchParams({
 | 
			
		||||
    appids: objectID,
 | 
			
		||||
    l: language,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return axios
 | 
			
		||||
    .get(
 | 
			
		||||
      `http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
 | 
			
		||||
    )
 | 
			
		||||
    .then((response) => {
 | 
			
		||||
      if (response.data[objectID].success) return response.data[objectID].data;
 | 
			
		||||
      return null;
 | 
			
		||||
    })
 | 
			
		||||
    .catch((err) => {
 | 
			
		||||
      logger.error(err, { method: "getSteamAppDetails" });
 | 
			
		||||
      throw new Error(err);
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										170
									
								
								src/main/services/torrent-client.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/main/services/torrent-client.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,170 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import cp from "node:child_process";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
import * as Sentry from "@sentry/electron/main";
 | 
			
		||||
import { Notification, app, dialog } from "electron";
 | 
			
		||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
 | 
			
		||||
 | 
			
		||||
import { Game } from "@main/entity";
 | 
			
		||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import { WindowManager } from "./window-manager";
 | 
			
		||||
 | 
			
		||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
 | 
			
		||||
  darwin: "hydra-download-manager",
 | 
			
		||||
  linux: "hydra-download-manager",
 | 
			
		||||
  win32: "hydra-download-manager.exe",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
enum TorrentState {
 | 
			
		||||
  CheckingFiles = 1,
 | 
			
		||||
  DownloadingMetadata = 2,
 | 
			
		||||
  Downloading = 3,
 | 
			
		||||
  Finished = 4,
 | 
			
		||||
  Seeding = 5,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface TorrentUpdate {
 | 
			
		||||
  gameId: number;
 | 
			
		||||
  progress: number;
 | 
			
		||||
  downloadSpeed: number;
 | 
			
		||||
  timeRemaining: number;
 | 
			
		||||
  numPeers: number;
 | 
			
		||||
  numSeeds: number;
 | 
			
		||||
  status: TorrentState;
 | 
			
		||||
  folderName: string;
 | 
			
		||||
  fileSize: number;
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const BITTORRENT_PORT = "5881";
 | 
			
		||||
 | 
			
		||||
export class TorrentClient {
 | 
			
		||||
  public static startTorrentClient(
 | 
			
		||||
    writePipePath: string,
 | 
			
		||||
    readPipePath: string
 | 
			
		||||
  ) {
 | 
			
		||||
    const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
 | 
			
		||||
 | 
			
		||||
    if (app.isPackaged) {
 | 
			
		||||
      const binaryName = binaryNameByPlatform[process.platform];
 | 
			
		||||
      const binaryPath = path.join(
 | 
			
		||||
        process.resourcesPath,
 | 
			
		||||
        "dist",
 | 
			
		||||
        "hydra-download-manager",
 | 
			
		||||
        binaryName
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (!fs.existsSync(binaryPath)) {
 | 
			
		||||
        dialog.showErrorBox(
 | 
			
		||||
          "Fatal",
 | 
			
		||||
          "Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        app.quit();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      cp.spawn(binaryPath, commonArgs, {
 | 
			
		||||
        stdio: "inherit",
 | 
			
		||||
        windowsHide: true,
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const scriptPath = path.join(
 | 
			
		||||
      __dirname,
 | 
			
		||||
      "..",
 | 
			
		||||
      "..",
 | 
			
		||||
      "torrent-client",
 | 
			
		||||
      "main.py"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    cp.spawn("python3", [scriptPath, ...commonArgs], {
 | 
			
		||||
      stdio: "inherit",
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static getTorrentStateName(state: TorrentState) {
 | 
			
		||||
    if (state === TorrentState.CheckingFiles) return "checking_files";
 | 
			
		||||
    if (state === TorrentState.Downloading) return "downloading";
 | 
			
		||||
    if (state === TorrentState.DownloadingMetadata)
 | 
			
		||||
      return "downloading_metadata";
 | 
			
		||||
    if (state === TorrentState.Finished) return "finished";
 | 
			
		||||
    if (state === TorrentState.Seeding) return "seeding";
 | 
			
		||||
    return "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static getGameProgress(game: Game) {
 | 
			
		||||
    if (game.status === "checking_files") return game.fileVerificationProgress;
 | 
			
		||||
    return game.progress;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static async onSocketData(data: Buffer) {
 | 
			
		||||
    const message = Buffer.from(data).toString("utf-8");
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const payload = JSON.parse(message) as TorrentUpdate;
 | 
			
		||||
 | 
			
		||||
      const updatePayload: QueryDeepPartialEntity<Game> = {
 | 
			
		||||
        bytesDownloaded: payload.bytesDownloaded,
 | 
			
		||||
        status: this.getTorrentStateName(payload.status),
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      if (payload.status === TorrentState.CheckingFiles) {
 | 
			
		||||
        updatePayload.fileVerificationProgress = payload.progress;
 | 
			
		||||
      } else {
 | 
			
		||||
        if (payload.folderName) {
 | 
			
		||||
          updatePayload.folderName = payload.folderName;
 | 
			
		||||
          updatePayload.fileSize = payload.fileSize;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        [TorrentState.Downloading, TorrentState.Seeding].includes(
 | 
			
		||||
          payload.status
 | 
			
		||||
        )
 | 
			
		||||
      ) {
 | 
			
		||||
        updatePayload.progress = payload.progress;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await gameRepository.update({ id: payload.gameId }, updatePayload);
 | 
			
		||||
 | 
			
		||||
      const game = await gameRepository.findOne({
 | 
			
		||||
        where: { id: payload.gameId },
 | 
			
		||||
        relations: { repack: true },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (game.progress === 1) {
 | 
			
		||||
        const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
          where: { id: 1 },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (userPreferences?.downloadNotificationsEnabled) {
 | 
			
		||||
          new Notification({
 | 
			
		||||
            title: t("download_complete", {
 | 
			
		||||
              ns: "notifications",
 | 
			
		||||
              lng: userPreferences.language,
 | 
			
		||||
            }),
 | 
			
		||||
            body: t("game_ready_to_install", {
 | 
			
		||||
              ns: "notifications",
 | 
			
		||||
              lng: userPreferences.language,
 | 
			
		||||
              title: game.title,
 | 
			
		||||
            }),
 | 
			
		||||
          }).show();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (WindowManager.mainWindow) {
 | 
			
		||||
        const progress = this.getGameProgress(game);
 | 
			
		||||
        WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
 | 
			
		||||
 | 
			
		||||
        WindowManager.mainWindow.webContents.send(
 | 
			
		||||
          "on-download-progress",
 | 
			
		||||
          JSON.parse(JSON.stringify({ ...payload, game }))
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      Sentry.captureException(err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										159
									
								
								src/main/services/update-resolver.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								src/main/services/update-resolver.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,159 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import { app } from "electron";
 | 
			
		||||
 | 
			
		||||
import { chunk } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
import { createDataSource, dataSource } from "@main/data-source";
 | 
			
		||||
import { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
 | 
			
		||||
import {
 | 
			
		||||
  migrationScriptRepository,
 | 
			
		||||
  repackRepository,
 | 
			
		||||
  repackerFriendlyNameRepository,
 | 
			
		||||
  steamGameRepository,
 | 
			
		||||
} from "@main/repository";
 | 
			
		||||
import { MigrationScript } from "@main/entity/migration-script.entity";
 | 
			
		||||
import { Like } from "typeorm";
 | 
			
		||||
 | 
			
		||||
const migrationScripts = {
 | 
			
		||||
  /*
 | 
			
		||||
    0.0.6 -> 0.0.7
 | 
			
		||||
    Xatab repacks were previously created with an incorrect upload date.
 | 
			
		||||
    This migration script will update the upload date of all Xatab repacks.
 | 
			
		||||
  */
 | 
			
		||||
  "0.0.7": async (updateRepacks: Repack[]) => {
 | 
			
		||||
    const VERSION = "0.0.7";
 | 
			
		||||
 | 
			
		||||
    const migrationScript = await migrationScriptRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        version: VERSION,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!migrationScript) {
 | 
			
		||||
      const xatabRepacks = updateRepacks.filter(
 | 
			
		||||
        (repack) => repack.repacker === "Xatab"
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      await dataSource.transaction(async (transactionalEntityManager) => {
 | 
			
		||||
        await Promise.all(
 | 
			
		||||
          xatabRepacks.map((repack) =>
 | 
			
		||||
            transactionalEntityManager.getRepository(Repack).update(
 | 
			
		||||
              {
 | 
			
		||||
                title: repack.title,
 | 
			
		||||
                repacker: repack.repacker,
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                uploadDate: repack.uploadDate,
 | 
			
		||||
              }
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        await transactionalEntityManager.getRepository(MigrationScript).insert({
 | 
			
		||||
          version: VERSION,
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  /*
 | 
			
		||||
    1.0.1 -> 1.1.0
 | 
			
		||||
    A few torrents scraped from 1337x were previously created with an incorrect upload date.
 | 
			
		||||
  */
 | 
			
		||||
  "1.1.0": async () => {
 | 
			
		||||
    const VERSION = "1.1.0";
 | 
			
		||||
 | 
			
		||||
    const migrationScript = await migrationScriptRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        version: VERSION,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!migrationScript) {
 | 
			
		||||
      await dataSource.transaction(async (transactionalEntityManager) => {
 | 
			
		||||
        const repacks = await transactionalEntityManager
 | 
			
		||||
          .getRepository(Repack)
 | 
			
		||||
          .find({
 | 
			
		||||
            where: {
 | 
			
		||||
              uploadDate: Like("1%"),
 | 
			
		||||
            },
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        await Promise.all(
 | 
			
		||||
          repacks.map(async (repack) => {
 | 
			
		||||
            return transactionalEntityManager
 | 
			
		||||
              .getRepository(Repack)
 | 
			
		||||
              .update(
 | 
			
		||||
                { id: repack.id },
 | 
			
		||||
                { uploadDate: new Date(repack.uploadDate) }
 | 
			
		||||
              );
 | 
			
		||||
          })
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        await transactionalEntityManager.getRepository(MigrationScript).insert({
 | 
			
		||||
          version: VERSION,
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const runMigrationScripts = async (updateRepacks: Repack[]) => {
 | 
			
		||||
  return Promise.all(
 | 
			
		||||
    Object.values(migrationScripts).map((migrationScript) => {
 | 
			
		||||
      return migrationScript(updateRepacks);
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const resolveDatabaseUpdates = async () => {
 | 
			
		||||
  const updateDataSource = createDataSource({
 | 
			
		||||
    database: app.isPackaged
 | 
			
		||||
      ? path.join(process.resourcesPath, "hydra.db")
 | 
			
		||||
      : path.join(__dirname, "..", "..", "resources", "hydra.db"),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return updateDataSource.initialize().then(async () => {
 | 
			
		||||
    const updateRepackRepository = updateDataSource.getRepository(Repack);
 | 
			
		||||
    const updateRepackerFriendlyNameRepository =
 | 
			
		||||
      updateDataSource.getRepository(RepackerFriendlyName);
 | 
			
		||||
    const updateSteamGameRepository = updateDataSource.getRepository(SteamGame);
 | 
			
		||||
 | 
			
		||||
    const [updateRepacks, updateSteamGames, updateRepackerFriendlyNames] =
 | 
			
		||||
      await Promise.all([
 | 
			
		||||
        updateRepackRepository.find(),
 | 
			
		||||
        updateSteamGameRepository.find(),
 | 
			
		||||
        updateRepackerFriendlyNameRepository.find(),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
    await runMigrationScripts(updateRepacks);
 | 
			
		||||
 | 
			
		||||
    await repackerFriendlyNameRepository
 | 
			
		||||
      .createQueryBuilder()
 | 
			
		||||
      .insert()
 | 
			
		||||
      .values(updateRepackerFriendlyNames)
 | 
			
		||||
      .orIgnore()
 | 
			
		||||
      .execute();
 | 
			
		||||
 | 
			
		||||
    const updateRepacksChunks = chunk(updateRepacks, 800);
 | 
			
		||||
 | 
			
		||||
    for (const chunk of updateRepacksChunks) {
 | 
			
		||||
      await repackRepository
 | 
			
		||||
        .createQueryBuilder()
 | 
			
		||||
        .insert()
 | 
			
		||||
        .values(chunk)
 | 
			
		||||
        .orIgnore()
 | 
			
		||||
        .execute();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const steamGamesChunks = chunk(updateSteamGames, 800);
 | 
			
		||||
 | 
			
		||||
    for (const chunk of steamGamesChunks) {
 | 
			
		||||
      await steamGameRepository
 | 
			
		||||
        .createQueryBuilder()
 | 
			
		||||
        .insert()
 | 
			
		||||
        .values(chunk)
 | 
			
		||||
        .orIgnore()
 | 
			
		||||
        .execute();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										109
									
								
								src/main/services/window-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/main/services/window-manager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,109 @@
 | 
			
		|||
import { BrowserWindow, Menu, Tray, app } from "electron";
 | 
			
		||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
export class WindowManager {
 | 
			
		||||
  public static mainWindow: Electron.BrowserWindow | null = null;
 | 
			
		||||
 | 
			
		||||
  public static createMainWindow() {
 | 
			
		||||
    // Create the browser window.
 | 
			
		||||
    this.mainWindow = new BrowserWindow({
 | 
			
		||||
      width: 1200,
 | 
			
		||||
      height: 720,
 | 
			
		||||
      minWidth: 1024,
 | 
			
		||||
      minHeight: 540,
 | 
			
		||||
      titleBarStyle: "hidden",
 | 
			
		||||
      // icon: path.join(__dirname, "..", "..", "images", "icon.png"),
 | 
			
		||||
      trafficLightPosition: { x: 16, y: 16 },
 | 
			
		||||
      titleBarOverlay: {
 | 
			
		||||
        symbolColor: "#DADBE1",
 | 
			
		||||
        color: "#151515",
 | 
			
		||||
        height: 34,
 | 
			
		||||
      },
 | 
			
		||||
      webPreferences: {
 | 
			
		||||
        preload: path.join(__dirname, "../preload/index.mjs"),
 | 
			
		||||
        sandbox: false,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.removeMenu();
 | 
			
		||||
 | 
			
		||||
    // HMR for renderer base on electron-vite cli.
 | 
			
		||||
    // Load the remote URL for development or the local html file for production.
 | 
			
		||||
    if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
 | 
			
		||||
      this.mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
 | 
			
		||||
    } else {
 | 
			
		||||
      this.mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.webContents.on("did-finish-load", () => {
 | 
			
		||||
      if (!app.isPackaged) {
 | 
			
		||||
        // Open the DevTools.
 | 
			
		||||
        this.mainWindow.webContents.openDevTools();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.on("close", () => {
 | 
			
		||||
      WindowManager.mainWindow.setProgressBar(-1);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static redirect(path: string) {
 | 
			
		||||
    if (!this.mainWindow) this.createMainWindow();
 | 
			
		||||
    this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
 | 
			
		||||
 | 
			
		||||
    if (this.mainWindow.isMinimized()) this.mainWindow.restore();
 | 
			
		||||
    this.mainWindow.focus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static createSystemTray(language: string) {
 | 
			
		||||
    const tray = new Tray(
 | 
			
		||||
      app.isPackaged
 | 
			
		||||
        ? path.join(process.resourcesPath, "icon_tray.png")
 | 
			
		||||
        : path.join(__dirname, "..", "..", "resources", "icon_tray.png")
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const contextMenu = Menu.buildFromTemplate([
 | 
			
		||||
      {
 | 
			
		||||
        label: t("open", {
 | 
			
		||||
          ns: "system_tray",
 | 
			
		||||
          lng: language,
 | 
			
		||||
        }),
 | 
			
		||||
        type: "normal",
 | 
			
		||||
        click: () => {
 | 
			
		||||
          if (this.mainWindow) {
 | 
			
		||||
            this.mainWindow.show();
 | 
			
		||||
          } else {
 | 
			
		||||
            this.createMainWindow();
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        label: t("quit", {
 | 
			
		||||
          ns: "system_tray",
 | 
			
		||||
          lng: language,
 | 
			
		||||
        }),
 | 
			
		||||
        type: "normal",
 | 
			
		||||
        click: () => app.quit(),
 | 
			
		||||
      },
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    tray.setToolTip("Hydra");
 | 
			
		||||
    tray.setContextMenu(contextMenu);
 | 
			
		||||
 | 
			
		||||
    if (process.platform === "win32") {
 | 
			
		||||
      tray.addListener("click", () => {
 | 
			
		||||
        if (this.mainWindow) {
 | 
			
		||||
          if (WindowManager.mainWindow.isMinimized())
 | 
			
		||||
            WindowManager.mainWindow.restore();
 | 
			
		||||
 | 
			
		||||
          WindowManager.mainWindow.focus();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.createMainWindow();
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								src/main/state-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/main/state-manager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import type { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
 | 
			
		||||
 | 
			
		||||
interface State {
 | 
			
		||||
  repacks: Repack[];
 | 
			
		||||
  repackersFriendlyNames: RepackerFriendlyName[];
 | 
			
		||||
  steamGames: SteamGame[];
 | 
			
		||||
  eventResults: Map<[string, any[]], any>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const initialState: State = {
 | 
			
		||||
  repacks: [],
 | 
			
		||||
  repackersFriendlyNames: [],
 | 
			
		||||
  steamGames: [],
 | 
			
		||||
  eventResults: new Map(),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class StateManager {
 | 
			
		||||
  private state = initialState;
 | 
			
		||||
 | 
			
		||||
  public setValue<T extends keyof State>(key: T, value: State[T]) {
 | 
			
		||||
    this.state = { ...this.state, [key]: value };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getValue<T extends keyof State>(key: T) {
 | 
			
		||||
    return this.state[key];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public clearValue<T extends keyof State>(key: T) {
 | 
			
		||||
    this.state = { ...this.state, [key]: initialState[key] };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const stateManager = new StateManager();
 | 
			
		||||
							
								
								
									
										12
									
								
								src/main/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/main/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
/// <reference types="vite/client" />
 | 
			
		||||
 | 
			
		||||
interface ImportMetaEnv {
 | 
			
		||||
  readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
 | 
			
		||||
  readonly MAIN_VITE_ONLINEFIX_USERNAME: string;
 | 
			
		||||
  readonly MAIN_VITE_ONLINEFIX_PASSWORD: string;
 | 
			
		||||
  readonly MAIN_VITE_SENTRY_DSN: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ImportMeta {
 | 
			
		||||
  readonly env: ImportMetaEnv;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										104
									
								
								src/preload/index.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/preload/index.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,104 @@
 | 
			
		|||
// See the Electron documentation for details on how to use preload scripts:
 | 
			
		||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
 | 
			
		||||
import { contextBridge, ipcRenderer } from "electron";
 | 
			
		||||
 | 
			
		||||
import type {
 | 
			
		||||
  CatalogueCategory,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  TorrentProgress,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
} from "@types";
 | 
			
		||||
 | 
			
		||||
contextBridge.exposeInMainWorld("electron", {
 | 
			
		||||
  /* Torrenting */
 | 
			
		||||
  startGameDownload: (
 | 
			
		||||
    repackId: number,
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop
 | 
			
		||||
  ) => ipcRenderer.invoke("startGameDownload", repackId, objectID, title, shop),
 | 
			
		||||
  cancelGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("cancelGameDownload", gameId),
 | 
			
		||||
  pauseGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("pauseGameDownload", gameId),
 | 
			
		||||
  resumeGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("resumeGameDownload", gameId),
 | 
			
		||||
  onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
 | 
			
		||||
    const listener = (
 | 
			
		||||
      _event: Electron.IpcRendererEvent,
 | 
			
		||||
      value: TorrentProgress
 | 
			
		||||
    ) => cb(value);
 | 
			
		||||
    ipcRenderer.on("on-download-progress", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-download-progress", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Catalogue */
 | 
			
		||||
  searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
 | 
			
		||||
  getCatalogue: (category: CatalogueCategory) =>
 | 
			
		||||
    ipcRenderer.invoke("getCatalogue", category),
 | 
			
		||||
  getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
 | 
			
		||||
  getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
 | 
			
		||||
  getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
 | 
			
		||||
  getGames: (take?: number, prevCursor?: number) =>
 | 
			
		||||
    ipcRenderer.invoke("getGames", take, prevCursor),
 | 
			
		||||
 | 
			
		||||
  /* User preferences */
 | 
			
		||||
  getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
 | 
			
		||||
  updateUserPreferences: (preferences: UserPreferences) =>
 | 
			
		||||
    ipcRenderer.invoke("updateUserPreferences", preferences),
 | 
			
		||||
 | 
			
		||||
  /* Library */
 | 
			
		||||
  addGameToLibrary: (
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop,
 | 
			
		||||
    executablePath: string
 | 
			
		||||
  ) =>
 | 
			
		||||
    ipcRenderer.invoke(
 | 
			
		||||
      "addGameToLibrary",
 | 
			
		||||
      objectID,
 | 
			
		||||
      title,
 | 
			
		||||
      shop,
 | 
			
		||||
      executablePath
 | 
			
		||||
    ),
 | 
			
		||||
  getLibrary: () => ipcRenderer.invoke("getLibrary"),
 | 
			
		||||
  getRepackersFriendlyNames: () =>
 | 
			
		||||
    ipcRenderer.invoke("getRepackersFriendlyNames"),
 | 
			
		||||
  openGameInstaller: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("openGameInstaller", gameId),
 | 
			
		||||
  openGame: (gameId: number, executablePath: string) =>
 | 
			
		||||
    ipcRenderer.invoke("openGame", gameId, executablePath),
 | 
			
		||||
  closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
 | 
			
		||||
  removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
 | 
			
		||||
  deleteGameFolder: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("deleteGameFolder", gameId),
 | 
			
		||||
  getGameByObjectID: (objectID: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameByObjectID", objectID),
 | 
			
		||||
  onPlaytime: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-playtime", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-playtime", listener);
 | 
			
		||||
  },
 | 
			
		||||
  onGameClose: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-game-close", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-game-close", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Hardware */
 | 
			
		||||
  getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
 | 
			
		||||
 | 
			
		||||
  /* Misc */
 | 
			
		||||
  getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
 | 
			
		||||
  ping: () => ipcRenderer.invoke("ping"),
 | 
			
		||||
  getVersion: () => ipcRenderer.invoke("getVersion"),
 | 
			
		||||
  getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
 | 
			
		||||
  openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
 | 
			
		||||
  showOpenDialog: (options: Electron.OpenDialogOptions) =>
 | 
			
		||||
    ipcRenderer.invoke("showOpenDialog", options),
 | 
			
		||||
  platform: process.platform,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										104
									
								
								src/preload/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/preload/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,104 @@
 | 
			
		|||
// See the Electron documentation for details on how to use preload scripts:
 | 
			
		||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
 | 
			
		||||
import { contextBridge, ipcRenderer } from "electron";
 | 
			
		||||
 | 
			
		||||
import type {
 | 
			
		||||
  CatalogueCategory,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  TorrentProgress,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
} from "@types";
 | 
			
		||||
 | 
			
		||||
contextBridge.exposeInMainWorld("electron", {
 | 
			
		||||
  /* Torrenting */
 | 
			
		||||
  startGameDownload: (
 | 
			
		||||
    repackId: number,
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop
 | 
			
		||||
  ) => ipcRenderer.invoke("startGameDownload", repackId, objectID, title, shop),
 | 
			
		||||
  cancelGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("cancelGameDownload", gameId),
 | 
			
		||||
  pauseGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("pauseGameDownload", gameId),
 | 
			
		||||
  resumeGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("resumeGameDownload", gameId),
 | 
			
		||||
  onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
 | 
			
		||||
    const listener = (
 | 
			
		||||
      _event: Electron.IpcRendererEvent,
 | 
			
		||||
      value: TorrentProgress
 | 
			
		||||
    ) => cb(value);
 | 
			
		||||
    ipcRenderer.on("on-download-progress", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-download-progress", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Catalogue */
 | 
			
		||||
  searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
 | 
			
		||||
  getCatalogue: (category: CatalogueCategory) =>
 | 
			
		||||
    ipcRenderer.invoke("getCatalogue", category),
 | 
			
		||||
  getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
 | 
			
		||||
  getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
 | 
			
		||||
  getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
 | 
			
		||||
  getGames: (take?: number, prevCursor?: number) =>
 | 
			
		||||
    ipcRenderer.invoke("getGames", take, prevCursor),
 | 
			
		||||
 | 
			
		||||
  /* User preferences */
 | 
			
		||||
  getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
 | 
			
		||||
  updateUserPreferences: (preferences: UserPreferences) =>
 | 
			
		||||
    ipcRenderer.invoke("updateUserPreferences", preferences),
 | 
			
		||||
 | 
			
		||||
  /* Library */
 | 
			
		||||
  addGameToLibrary: (
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop,
 | 
			
		||||
    executablePath: string
 | 
			
		||||
  ) =>
 | 
			
		||||
    ipcRenderer.invoke(
 | 
			
		||||
      "addGameToLibrary",
 | 
			
		||||
      objectID,
 | 
			
		||||
      title,
 | 
			
		||||
      shop,
 | 
			
		||||
      executablePath
 | 
			
		||||
    ),
 | 
			
		||||
  getLibrary: () => ipcRenderer.invoke("getLibrary"),
 | 
			
		||||
  getRepackersFriendlyNames: () =>
 | 
			
		||||
    ipcRenderer.invoke("getRepackersFriendlyNames"),
 | 
			
		||||
  openGameInstaller: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("openGameInstaller", gameId),
 | 
			
		||||
  openGame: (gameId: number, executablePath: string) =>
 | 
			
		||||
    ipcRenderer.invoke("openGame", gameId, executablePath),
 | 
			
		||||
  closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
 | 
			
		||||
  removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
 | 
			
		||||
  deleteGameFolder: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("deleteGameFolder", gameId),
 | 
			
		||||
  getGameByObjectID: (objectID: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameByObjectID", objectID),
 | 
			
		||||
  onPlaytime: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-playtime", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-playtime", listener);
 | 
			
		||||
  },
 | 
			
		||||
  onGameClose: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-game-close", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-game-close", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Hardware */
 | 
			
		||||
  getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
 | 
			
		||||
 | 
			
		||||
  /* Misc */
 | 
			
		||||
  getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
 | 
			
		||||
  ping: () => ipcRenderer.invoke("ping"),
 | 
			
		||||
  getVersion: () => ipcRenderer.invoke("getVersion"),
 | 
			
		||||
  getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
 | 
			
		||||
  openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
 | 
			
		||||
  showOpenDialog: (options: Electron.OpenDialogOptions) =>
 | 
			
		||||
    ipcRenderer.invoke("showOpenDialog", options),
 | 
			
		||||
  platform: process.platform,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										17
									
								
								src/renderer/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/renderer/index.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <title>Electron</title>
 | 
			
		||||
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
 | 
			
		||||
    <!-- <meta
 | 
			
		||||
      http-equiv="Content-Security-Policy"
 | 
			
		||||
      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
 | 
			
		||||
    /> -->
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
    <script type="module" src="/src/main.tsx"></script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										107
									
								
								src/renderer/src/app.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/renderer/src/app.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,107 @@
 | 
			
		|||
import { ComplexStyleRule, globalStyle, style } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "./theme.css";
 | 
			
		||||
 | 
			
		||||
globalStyle("*", {
 | 
			
		||||
  boxSizing: "border-box",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar", {
 | 
			
		||||
  width: "9px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar-track", {
 | 
			
		||||
  backgroundColor: "rgba(255, 255, 255, 0.03)",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar-thumb", {
 | 
			
		||||
  backgroundColor: "rgba(255, 255, 255, 0.08)",
 | 
			
		||||
  borderRadius: "24px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("html, body, #root, main", {
 | 
			
		||||
  height: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("body", {
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  userSelect: "none",
 | 
			
		||||
  fontFamily: "'Fira Mono', monospace",
 | 
			
		||||
  background: vars.color.background,
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
  margin: "0",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("button", {
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  backgroundColor: "transparent",
 | 
			
		||||
  border: "none",
 | 
			
		||||
  fontFamily: "inherit",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("h1, h2, h3, h4, h5, h6, p", {
 | 
			
		||||
  margin: 0,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("#root, main", {
 | 
			
		||||
  display: "flex",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("#root", {
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("main", {
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle(
 | 
			
		||||
  "input::-webkit-outer-spin-button, input::-webkit-inner-spin-button",
 | 
			
		||||
  {
 | 
			
		||||
    WebkitAppearance: "none",
 | 
			
		||||
    margin: "0",
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
globalStyle("label", {
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("input[type=number]", {
 | 
			
		||||
  MozAppearance: "textfield",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("img", {
 | 
			
		||||
  WebkitUserDrag: "none",
 | 
			
		||||
} as Record<string, string>);
 | 
			
		||||
 | 
			
		||||
export const container = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
  overflowY: "auto",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  background: `linear-gradient(0deg, ${vars.color.darkBackground} 50%, ${vars.color.background} 100%)`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const titleBar = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "35px",
 | 
			
		||||
  minHeight: "35px",
 | 
			
		||||
  backgroundColor: vars.color.darkBackground,
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  padding: `0 ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  WebkitAppRegion: "drag",
 | 
			
		||||
  zIndex: "2",
 | 
			
		||||
  borderBottom: `1px solid ${vars.color.borderColor}`,
 | 
			
		||||
} as ComplexStyleRule);
 | 
			
		||||
							
								
								
									
										122
									
								
								src/renderer/src/app.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/renderer/src/app.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,122 @@
 | 
			
		|||
import { useCallback, useEffect, useRef } from "react";
 | 
			
		||||
 | 
			
		||||
import { Sidebar, BottomPanel, Header } from "@renderer/components";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  useAppDispatch,
 | 
			
		||||
  useAppSelector,
 | 
			
		||||
  useDownload,
 | 
			
		||||
  useLibrary,
 | 
			
		||||
} from "@renderer/hooks";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./app.css";
 | 
			
		||||
import { themeClass } from "./theme.css";
 | 
			
		||||
 | 
			
		||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import {
 | 
			
		||||
  setSearch,
 | 
			
		||||
  clearSearch,
 | 
			
		||||
  setUserPreferences,
 | 
			
		||||
  setRepackersFriendlyNames,
 | 
			
		||||
} from "@renderer/features";
 | 
			
		||||
 | 
			
		||||
document.body.classList.add(themeClass);
 | 
			
		||||
 | 
			
		||||
export function App() {
 | 
			
		||||
  const contentRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const { updateLibrary } = useLibrary();
 | 
			
		||||
 | 
			
		||||
  const { clearDownload, addPacket } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
 | 
			
		||||
  const search = useAppSelector((state) => state.search.value);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    Promise.all([
 | 
			
		||||
      window.electron.getUserPreferences(),
 | 
			
		||||
      window.electron.getRepackersFriendlyNames(),
 | 
			
		||||
      updateLibrary(),
 | 
			
		||||
    ]).then(([preferences, repackersFriendlyNames]) => {
 | 
			
		||||
      dispatch(setUserPreferences(preferences));
 | 
			
		||||
      dispatch(setRepackersFriendlyNames(repackersFriendlyNames));
 | 
			
		||||
    });
 | 
			
		||||
  }, [navigate, location.pathname, dispatch, updateLibrary]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const unsubscribe = window.electron.onDownloadProgress(
 | 
			
		||||
      (downloadProgress) => {
 | 
			
		||||
        if (downloadProgress.game.progress === 1) {
 | 
			
		||||
          clearDownload();
 | 
			
		||||
          updateLibrary();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        addPacket(downloadProgress);
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      unsubscribe();
 | 
			
		||||
    };
 | 
			
		||||
  }, [clearDownload, addPacket, updateLibrary]);
 | 
			
		||||
 | 
			
		||||
  const handleSearch = useCallback(
 | 
			
		||||
    (query: string) => {
 | 
			
		||||
      dispatch(setSearch(query));
 | 
			
		||||
 | 
			
		||||
      if (query === "") {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const searchParams = new URLSearchParams({
 | 
			
		||||
        query,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      navigate(`/search?${searchParams.toString()}`, {
 | 
			
		||||
        replace: location.pathname.startsWith("/search"),
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [dispatch, location.pathname, navigate]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleClear = useCallback(() => {
 | 
			
		||||
    dispatch(clearSearch());
 | 
			
		||||
    navigate(-1);
 | 
			
		||||
  }, [dispatch, navigate]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (contentRef.current) contentRef.current.scrollTop = 0;
 | 
			
		||||
  }, [location.pathname, location.search]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {window.electron.platform === "win32" && (
 | 
			
		||||
        <div className={styles.titleBar}>
 | 
			
		||||
          <h4>Hydra</h4>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <main>
 | 
			
		||||
        <Sidebar />
 | 
			
		||||
 | 
			
		||||
        <article className={styles.container}>
 | 
			
		||||
          <Header
 | 
			
		||||
            onSearch={handleSearch}
 | 
			
		||||
            search={search}
 | 
			
		||||
            onClear={handleClear}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <section ref={contentRef} className={styles.content}>
 | 
			
		||||
            <Outlet />
 | 
			
		||||
          </section>
 | 
			
		||||
        </article>
 | 
			
		||||
      </main>
 | 
			
		||||
      <BottomPanel />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue