mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	b42d1d73679928412690780047914b6124c1f0ee
This commit is contained in:
		
						commit
						3d78a852b5
					
				
					 146 changed files with 37727 additions and 0 deletions
				
			
		
							
								
								
									
										1
									
								
								.env.example
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.env.example
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
STEAMGRIDDB_API_KEY=YOUR_API_KEY
 | 
			
		||||
							
								
								
									
										46
									
								
								.eslintrc.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								.eslintrc.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
module.exports = {
 | 
			
		||||
  env: {
 | 
			
		||||
    browser: true,
 | 
			
		||||
    es2021: true,
 | 
			
		||||
  },
 | 
			
		||||
  settings: {
 | 
			
		||||
    react: {
 | 
			
		||||
      version: "detect",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  extends: [
 | 
			
		||||
    "eslint:recommended",
 | 
			
		||||
    "plugin:@typescript-eslint/recommended",
 | 
			
		||||
    "plugin:react/jsx-runtime",
 | 
			
		||||
    "plugin:react-hooks/recommended",
 | 
			
		||||
    "prettier",
 | 
			
		||||
  ],
 | 
			
		||||
  overrides: [
 | 
			
		||||
    {
 | 
			
		||||
      env: {
 | 
			
		||||
        node: true,
 | 
			
		||||
      },
 | 
			
		||||
      files: [".eslintrc.{js,cjs}"],
 | 
			
		||||
      parserOptions: {
 | 
			
		||||
        sourceType: "script",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  parser: "@typescript-eslint/parser",
 | 
			
		||||
  parserOptions: {
 | 
			
		||||
    ecmaVersion: "latest",
 | 
			
		||||
    sourceType: "module",
 | 
			
		||||
  },
 | 
			
		||||
  plugins: ["@typescript-eslint", "react"],
 | 
			
		||||
  rules: {
 | 
			
		||||
    "@typescript-eslint/no-unused-vars": [
 | 
			
		||||
      "error",
 | 
			
		||||
      {
 | 
			
		||||
        argsIgnorePattern: "^_",
 | 
			
		||||
        varsIgnorePattern: "^_",
 | 
			
		||||
        caughtErrorsIgnorePattern: "^_",
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    "@typescript-eslint/no-explicit-any": "warn",
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										48
									
								
								.github/workflows/build.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/build.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
name: Build
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches: main
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
    runs-on: ${{ matrix.os }}
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        os: [windows-latest, ubuntu-latest]
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Check out Git repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install Node.js
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.11.1
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: yarn
 | 
			
		||||
 | 
			
		||||
      - name: Lint
 | 
			
		||||
        run: yarn lint
 | 
			
		||||
 | 
			
		||||
      - name: Install Python
 | 
			
		||||
        uses: actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: 3.9
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: pip install -r requirements.txt
 | 
			
		||||
 | 
			
		||||
      - name: Build with pyinstaller
 | 
			
		||||
        run: pyinstaller torrent-client/main.py --distpath resources/dist --icon=images/icon.ico -n hydra-download-manager
 | 
			
		||||
 | 
			
		||||
      - name: Publish
 | 
			
		||||
        run: yarn run publish
 | 
			
		||||
 | 
			
		||||
      - name: Create artifact
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: Build
 | 
			
		||||
          path: out/Hydra-win32-x64
 | 
			
		||||
							
								
								
									
										26
									
								
								.github/workflows/lint.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/lint.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
name: Lint
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - "**"
 | 
			
		||||
      - "!main"
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  lint:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Check out Git repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install Node.js
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.11.1
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: yarn
 | 
			
		||||
 | 
			
		||||
      - name: Lint
 | 
			
		||||
        run: yarn lint
 | 
			
		||||
							
								
								
									
										26
									
								
								.github/workflows/sync.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/sync.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
name: Sync
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches: "**"
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  sync:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Check out Git repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Sync with remote repository
 | 
			
		||||
        run: |
 | 
			
		||||
          rm -rf .git
 | 
			
		||||
          git init
 | 
			
		||||
          git remote add origin https://hydralauncher:${{ secrets.HYDRA_GITHUB_SECRET }}@github.com/hydralauncher/hydra
 | 
			
		||||
          git config --global init.defaultBranch main
 | 
			
		||||
          git config --global user.name "GitHub Actions"
 | 
			
		||||
          git config --global user.email "<>"
 | 
			
		||||
          git checkout -b main
 | 
			
		||||
          git add .
 | 
			
		||||
          git commit -a -m "$GITHUB_SHA"
 | 
			
		||||
          git push origin main --force
 | 
			
		||||
							
								
								
									
										103
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,103 @@
 | 
			
		|||
# Logs
 | 
			
		||||
logs
 | 
			
		||||
*.log
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
lerna-debug.log*
 | 
			
		||||
 | 
			
		||||
# Diagnostic reports (https://nodejs.org/api/report.html)
 | 
			
		||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
 | 
			
		||||
 | 
			
		||||
# Runtime data
 | 
			
		||||
pids
 | 
			
		||||
*.pid
 | 
			
		||||
*.seed
 | 
			
		||||
*.pid.lock
 | 
			
		||||
.DS_Store
 | 
			
		||||
 | 
			
		||||
# Directory for instrumented libs generated by jscoverage/JSCover
 | 
			
		||||
lib-cov
 | 
			
		||||
 | 
			
		||||
# Coverage directory used by tools like istanbul
 | 
			
		||||
coverage
 | 
			
		||||
*.lcov
 | 
			
		||||
 | 
			
		||||
# nyc test coverage
 | 
			
		||||
.nyc_output
 | 
			
		||||
 | 
			
		||||
# node-waf configuration
 | 
			
		||||
.lock-wscript
 | 
			
		||||
 | 
			
		||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
 | 
			
		||||
build/Release
 | 
			
		||||
 | 
			
		||||
# Dependency directories
 | 
			
		||||
node_modules/
 | 
			
		||||
jspm_packages/
 | 
			
		||||
 | 
			
		||||
# TypeScript v1 declaration files
 | 
			
		||||
typings/
 | 
			
		||||
 | 
			
		||||
# TypeScript cache
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
 | 
			
		||||
# Optional npm cache directory
 | 
			
		||||
.npm
 | 
			
		||||
 | 
			
		||||
# Optional eslint cache
 | 
			
		||||
.eslintcache
 | 
			
		||||
 | 
			
		||||
# Optional REPL history
 | 
			
		||||
.node_repl_history
 | 
			
		||||
 | 
			
		||||
# Output of 'npm pack'
 | 
			
		||||
*.tgz
 | 
			
		||||
 | 
			
		||||
# Yarn Integrity file
 | 
			
		||||
.yarn-integrity
 | 
			
		||||
 | 
			
		||||
# dotenv environment variables file
 | 
			
		||||
.env
 | 
			
		||||
.env.test
 | 
			
		||||
 | 
			
		||||
# parcel-bundler cache (https://parceljs.org/)
 | 
			
		||||
.cache
 | 
			
		||||
 | 
			
		||||
# next.js build output
 | 
			
		||||
.next
 | 
			
		||||
 | 
			
		||||
# nuxt.js build output
 | 
			
		||||
.nuxt
 | 
			
		||||
 | 
			
		||||
# vuepress build output
 | 
			
		||||
.vuepress/dist
 | 
			
		||||
 | 
			
		||||
# Serverless directories
 | 
			
		||||
.serverless/
 | 
			
		||||
 | 
			
		||||
# FuseBox cache
 | 
			
		||||
.fusebox/
 | 
			
		||||
 | 
			
		||||
# DynamoDB Local files
 | 
			
		||||
.dynamodb/
 | 
			
		||||
 | 
			
		||||
# Webpack
 | 
			
		||||
.webpack/
 | 
			
		||||
 | 
			
		||||
# Vite
 | 
			
		||||
.vite/
 | 
			
		||||
 | 
			
		||||
# Electron-Forge
 | 
			
		||||
out/
 | 
			
		||||
 | 
			
		||||
.vscode/
 | 
			
		||||
 | 
			
		||||
dev.db
 | 
			
		||||
 | 
			
		||||
__pycache__
 | 
			
		||||
 | 
			
		||||
# pyinstaller
 | 
			
		||||
build/
 | 
			
		||||
resources/dist/
 | 
			
		||||
*.spec
 | 
			
		||||
							
								
								
									
										21
									
								
								LICENSE
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								LICENSE
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
MIT License
 | 
			
		||||
 | 
			
		||||
Copyright (c) 2024 Los Broxas
 | 
			
		||||
 | 
			
		||||
Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
in the Software without restriction, including without limitation the rights
 | 
			
		||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
furnished to do so, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
copies or substantial portions of the Software.
 | 
			
		||||
 | 
			
		||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
SOFTWARE.
 | 
			
		||||
							
								
								
									
										74
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
# Hydra
 | 
			
		||||
 | 
			
		||||
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](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](yarnpkg.com).
 | 
			
		||||
 | 
			
		||||
### 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](python.org).
 | 
			
		||||
 | 
			
		||||
### 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.
 | 
			
		||||
Once you have it, you can paste the `.env.example` file and put it on `STEAMGRIDDB_API_KEY`.
 | 
			
		||||
 | 
			
		||||
## Running
 | 
			
		||||
 | 
			
		||||
Once you've installed all dependencies, you can build and run Hydra Download Manager. Here are the basic commands:
 | 
			
		||||
 | 
			
		||||
## Build
 | 
			
		||||
 | 
			
		||||
### Build the bittorrent client
 | 
			
		||||
 | 
			
		||||
Build the bittorrent client by using this command:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
pyinstaller torrent-client/main.py --distpath resources/dist --icon=images/icon.ico -n hydra-download-manager
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Build the Electron application
 | 
			
		||||
 | 
			
		||||
Build the Electron application by using this command:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
yarn make
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
Hydra is licensed under the [MIT License](LICENSE).
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								docs/screenshot.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								docs/screenshot.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 390 KiB  | 
							
								
								
									
										89
									
								
								forge.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								forge.config.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import type { ForgeConfig } from "@electron-forge/shared-types";
 | 
			
		||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
 | 
			
		||||
import { MakerZIP } from "@electron-forge/maker-zip";
 | 
			
		||||
import { MakerDeb } from "@electron-forge/maker-deb";
 | 
			
		||||
import { MakerRpm } from "@electron-forge/maker-rpm";
 | 
			
		||||
import { AutoUnpackNativesPlugin } from "@electron-forge/plugin-auto-unpack-natives";
 | 
			
		||||
import { WebpackPlugin } from "@electron-forge/plugin-webpack";
 | 
			
		||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
 | 
			
		||||
import { PublisherGithub } from "@electron-forge/publisher-github";
 | 
			
		||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
 | 
			
		||||
import { ElectronegativityPlugin } from "@electron-forge/plugin-electronegativity";
 | 
			
		||||
 | 
			
		||||
import { mainConfig } from "./webpack.main.config";
 | 
			
		||||
import { rendererConfig } from "./webpack.renderer.config";
 | 
			
		||||
 | 
			
		||||
const config: ForgeConfig = {
 | 
			
		||||
  packagerConfig: {
 | 
			
		||||
    asar: true,
 | 
			
		||||
    icon: "./images/icon.png",
 | 
			
		||||
    extraResource: [
 | 
			
		||||
      "./resources/hydra.db",
 | 
			
		||||
      "./resources/icon_tray.png",
 | 
			
		||||
      "./resources/dist",
 | 
			
		||||
    ],
 | 
			
		||||
    protocols: [
 | 
			
		||||
      {
 | 
			
		||||
        name: "Hydra",
 | 
			
		||||
        schemes: ["hydralauncher"],
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
  rebuildConfig: {},
 | 
			
		||||
  makers: [
 | 
			
		||||
    new MakerSquirrel({
 | 
			
		||||
      setupIcon: "./images/icon.ico",
 | 
			
		||||
    }),
 | 
			
		||||
    new MakerZIP({}, ["darwin"]),
 | 
			
		||||
    new MakerRpm({}),
 | 
			
		||||
    new MakerDeb({
 | 
			
		||||
      options: {
 | 
			
		||||
        mimeType: ["x-scheme-handler/hydralauncher"],
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  ],
 | 
			
		||||
  publishers: [
 | 
			
		||||
    new PublisherGithub({
 | 
			
		||||
      repository: {
 | 
			
		||||
        owner: "hydralauncher",
 | 
			
		||||
        name: "hydra",
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  ],
 | 
			
		||||
  plugins: [
 | 
			
		||||
    new AutoUnpackNativesPlugin({}),
 | 
			
		||||
    new WebpackPlugin({
 | 
			
		||||
      mainConfig,
 | 
			
		||||
      devContentSecurityPolicy: "connect-src 'self' * 'unsafe-eval'",
 | 
			
		||||
      renderer: {
 | 
			
		||||
        config: rendererConfig,
 | 
			
		||||
        entryPoints: [
 | 
			
		||||
          {
 | 
			
		||||
            html: "./src/index.html",
 | 
			
		||||
            js: "./src/renderer.ts",
 | 
			
		||||
            name: "main_window",
 | 
			
		||||
            preload: {
 | 
			
		||||
              js: "./src/preload.ts",
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    // Fuses are used to enable/disable various Electron functionality
 | 
			
		||||
    // at package time, before code signing the application
 | 
			
		||||
    new FusesPlugin({
 | 
			
		||||
      version: FuseVersion.V1,
 | 
			
		||||
      [FuseV1Options.RunAsNode]: false,
 | 
			
		||||
      [FuseV1Options.EnableCookieEncryption]: true,
 | 
			
		||||
      [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
 | 
			
		||||
      [FuseV1Options.EnableNodeCliInspectArguments]: false,
 | 
			
		||||
      [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
 | 
			
		||||
      [FuseV1Options.OnlyLoadAppFromAsar]: true,
 | 
			
		||||
    }),
 | 
			
		||||
    new ElectronegativityPlugin({
 | 
			
		||||
      isSarif: true,
 | 
			
		||||
    }),
 | 
			
		||||
  ],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default config;
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								images/icon.ico
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								images/icon.ico
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 11 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								images/icon.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								images/icon.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 188 KiB  | 
							
								
								
									
										19174
									
								
								package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										19174
									
								
								package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										103
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								package.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,103 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "hydra",
 | 
			
		||||
  "productName": "Hydra",
 | 
			
		||||
  "version": "1.0.0+steamdb-rotation",
 | 
			
		||||
  "description": "No bullshit. Just play.",
 | 
			
		||||
  "main": ".webpack/main",
 | 
			
		||||
  "repository": {
 | 
			
		||||
    "url": "https://github.com/hydralauncher/hydra"
 | 
			
		||||
  },
 | 
			
		||||
  "author": {
 | 
			
		||||
    "name": "Hydra",
 | 
			
		||||
    "email": "hydra@hydralauncher.site"
 | 
			
		||||
  },
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "electron-forge start",
 | 
			
		||||
    "package": "electron-forge package",
 | 
			
		||||
    "make": "electron-forge make",
 | 
			
		||||
    "publish": "electron-forge publish",
 | 
			
		||||
    "lint": "eslint ."
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@electron-forge/cli": "^7.3.0",
 | 
			
		||||
    "@electron-forge/maker-deb": "^7.3.0",
 | 
			
		||||
    "@electron-forge/maker-rpm": "^7.3.0",
 | 
			
		||||
    "@electron-forge/maker-squirrel": "^7.3.0",
 | 
			
		||||
    "@electron-forge/maker-zip": "^7.3.0",
 | 
			
		||||
    "@electron-forge/plugin-auto-unpack-natives": "^7.3.0",
 | 
			
		||||
    "@electron-forge/plugin-electronegativity": "^7.3.0",
 | 
			
		||||
    "@electron-forge/plugin-fuses": "^7.3.0",
 | 
			
		||||
    "@electron-forge/plugin-webpack": "^7.3.0",
 | 
			
		||||
    "@electron-forge/publisher-github": "^7.3.0",
 | 
			
		||||
    "@electron/fuses": "^1.7.0",
 | 
			
		||||
    "@svgr/webpack": "^8.1.0",
 | 
			
		||||
    "@types/color": "^3.0.6",
 | 
			
		||||
    "@types/dotenv-webpack": "^7.0.7",
 | 
			
		||||
    "@types/jsdom": "^21.1.6",
 | 
			
		||||
    "@types/lodash": "^4.17.0",
 | 
			
		||||
    "@types/react": "^18.2.66",
 | 
			
		||||
    "@types/react-dom": "^18.2.22",
 | 
			
		||||
    "@types/uuid": "^9.0.8",
 | 
			
		||||
    "@types/webtorrent": "^0.109.8",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^7.3.1",
 | 
			
		||||
    "@typescript-eslint/parser": "^7.3.1",
 | 
			
		||||
    "@vanilla-extract/webpack-plugin": "^2.3.7",
 | 
			
		||||
    "@vercel/webpack-asset-relocator-loader": "1.7.3",
 | 
			
		||||
    "css-loader": "^6.0.0",
 | 
			
		||||
    "dotenv-webpack": "^8.1.0",
 | 
			
		||||
    "electron": "29.1.4",
 | 
			
		||||
    "eslint": "^8.57.0",
 | 
			
		||||
    "eslint-config-prettier": "^9.1.0",
 | 
			
		||||
    "eslint-plugin-react": "^7.34.1",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^4.6.0",
 | 
			
		||||
    "fork-ts-checker-webpack-plugin": "^7.2.13",
 | 
			
		||||
    "node-loader": "^2.0.0",
 | 
			
		||||
    "style-loader": "^3.0.0",
 | 
			
		||||
    "ts-loader": "^9.2.2",
 | 
			
		||||
    "ts-node": "^10.0.0",
 | 
			
		||||
    "tsconfig-paths": "^4.2.0",
 | 
			
		||||
    "tsconfig-paths-webpack-plugin": "^4.1.0",
 | 
			
		||||
    "typescript": "^5.4.3"
 | 
			
		||||
  },
 | 
			
		||||
  "keywords": [],
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fontsource/fira-mono": "^5.0.12",
 | 
			
		||||
    "@fontsource/fira-sans": "^5.0.19",
 | 
			
		||||
    "@msgpack/msgpack": "^3.0.0-beta2",
 | 
			
		||||
    "@primer/octicons-react": "^19.8.0",
 | 
			
		||||
    "@reduxjs/toolkit": "^2.2.2",
 | 
			
		||||
    "@vanilla-extract/css": "^1.14.1",
 | 
			
		||||
    "@vanilla-extract/recipes": "^0.5.2",
 | 
			
		||||
    "@vanilla-extract/vite-plugin": "^4.0.6",
 | 
			
		||||
    "@vitejs/plugin-react-swc": "^3.6.0",
 | 
			
		||||
    "axios": "^1.6.8",
 | 
			
		||||
    "check-disk-space": "^3.4.0",
 | 
			
		||||
    "classnames": "^2.5.1",
 | 
			
		||||
    "color": "^4.2.3",
 | 
			
		||||
    "color.js": "^1.2.0",
 | 
			
		||||
    "date-fns": "^3.5.0",
 | 
			
		||||
    "electron-squirrel-startup": "^1.0.0",
 | 
			
		||||
    "flexsearch": "^0.7.43",
 | 
			
		||||
    "i18next": "^23.10.1",
 | 
			
		||||
    "i18next-browser-languagedetector": "^7.2.0",
 | 
			
		||||
    "jsdom": "^24.0.0",
 | 
			
		||||
    "lodash": "^4.17.21",
 | 
			
		||||
    "parse-torrent": "9.1.5",
 | 
			
		||||
    "pretty-bytes": "^6.1.1",
 | 
			
		||||
    "react": "^18.2.0",
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "react-i18next": "^14.1.0",
 | 
			
		||||
    "react-loading-skeleton": "^3.4.0",
 | 
			
		||||
    "react-redux": "^9.1.0",
 | 
			
		||||
    "react-router-dom": "^6.22.3",
 | 
			
		||||
    "sqlite3": "^5.1.7",
 | 
			
		||||
    "systeminformation": "^5.22.3",
 | 
			
		||||
    "typeorm": "^0.3.20",
 | 
			
		||||
    "update-electron-app": "^3.0.0",
 | 
			
		||||
    "uuid": "^9.0.1",
 | 
			
		||||
    "vite-plugin-svgr": "^4.2.0",
 | 
			
		||||
    "vite-tsconfig-paths": "^4.3.2",
 | 
			
		||||
    "winston": "^3.12.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
libtorrent
 | 
			
		||||
pyinstaller
 | 
			
		||||
pywin32; sys_platform == 'win32'
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								resources/hydra.db
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								resources/hydra.db
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								resources/icon_tray.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								resources/icon_tray.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 2.6 KiB  | 
							
								
								
									
										11
									
								
								src/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/index.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
			
		||||
    <title>Hydra</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										108
									
								
								src/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
import { app, BrowserWindow } from "electron";
 | 
			
		||||
import i18n from "i18next";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import {
 | 
			
		||||
  getSteamDBAlgoliaCredentials,
 | 
			
		||||
  logger,
 | 
			
		||||
  resolveDatabaseUpdates,
 | 
			
		||||
  WindowManager,
 | 
			
		||||
} from "@main/services";
 | 
			
		||||
import { updateElectronApp } from "update-electron-app";
 | 
			
		||||
import { dataSource } from "@main/data-source";
 | 
			
		||||
import * as resources from "@locales";
 | 
			
		||||
import { userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
const gotTheLock = app.requestSingleInstanceLock();
 | 
			
		||||
if (!gotTheLock) app.quit();
 | 
			
		||||
 | 
			
		||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
 | 
			
		||||
if (require("electron-squirrel-startup")) app.quit();
 | 
			
		||||
 | 
			
		||||
if (process.platform !== "darwin") {
 | 
			
		||||
  updateElectronApp();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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.on("ready", () => {
 | 
			
		||||
  dataSource.initialize().then(async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const algoliaCredentials = await getSteamDBAlgoliaCredentials();
 | 
			
		||||
      stateManager.setValue("steamDBAlgoliaCredentials", algoliaCredentials);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logger.error(err, { method: "getSteamDBAlgoliaCredentials" });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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.
 | 
			
		||||
							
								
								
									
										111
									
								
								src/locales/en/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/locales/en/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
{
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "featured": "Featured",
 | 
			
		||||
    "recently_added": "Recently added",
 | 
			
		||||
    "trending": "Trending",
 | 
			
		||||
    "surprise_me": "✨ Surprise me"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Search",
 | 
			
		||||
    "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}}",
 | 
			
		||||
    "deleting": "Deleting files…"
 | 
			
		||||
  },
 | 
			
		||||
  "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}}",
 | 
			
		||||
    "launch": "Launch",
 | 
			
		||||
    "resume": "Resume",
 | 
			
		||||
    "pause": "Pause",
 | 
			
		||||
    "cancel": "Cancel",
 | 
			
		||||
    "remove": "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)",
 | 
			
		||||
    "deleting": "Deleting files…",
 | 
			
		||||
    "delete": "Remove all files",
 | 
			
		||||
    "release_date": "Released in {{date}}",
 | 
			
		||||
    "publisher": "Published by {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copy link",
 | 
			
		||||
    "copied_link_to_clipboard": "Link copied"
 | 
			
		||||
  },
 | 
			
		||||
  "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": {
 | 
			
		||||
    "launch": "Launch",
 | 
			
		||||
    "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 files…",
 | 
			
		||||
    "delete": "Remove all files"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										111
									
								
								src/locales/es/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/locales/es/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
{
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "featured": "Destacado",
 | 
			
		||||
    "recently_added": "Recién Añadidos",
 | 
			
		||||
    "trending": "Tendencias",
 | 
			
		||||
    "surprise_me": "✨ ¡Sorpréndeme!"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Buscar",
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Descargas",
 | 
			
		||||
    "search_results": "Resultados de búsqueda",
 | 
			
		||||
    "settings": "Ajustes"
 | 
			
		||||
  },
 | 
			
		||||
  "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}}",
 | 
			
		||||
    "deleting": "Eliminando archivos…"
 | 
			
		||||
  },
 | 
			
		||||
  "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}}",
 | 
			
		||||
    "launch": "Iniciar",
 | 
			
		||||
    "resume": "Continuar",
 | 
			
		||||
    "pause": "Pausa",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "remove": "Eliminar",
 | 
			
		||||
    "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)",
 | 
			
		||||
    "deleting": "Eliminando archivos…",
 | 
			
		||||
    "delete": "Eliminar todos los archivos",
 | 
			
		||||
    "release_date": "Fecha de lanzamiento {{date}}",
 | 
			
		||||
    "publisher": "Publicado por {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copiar enlace",
 | 
			
		||||
    "copied_link_to_clipboard": "Enlace copiado"
 | 
			
		||||
  },
 | 
			
		||||
  "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": {
 | 
			
		||||
    "launch": "Iniciar",
 | 
			
		||||
    "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…",
 | 
			
		||||
    "deleting": "Eliminando archivos…",
 | 
			
		||||
    "delete": "Eliminar todos los archivos"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								src/locales/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/locales/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
export { default as en } from "./en/translation.json";
 | 
			
		||||
export { default as pt } from "./pt/translation.json";
 | 
			
		||||
export { default as es } from "./es/translation.json";
 | 
			
		||||
							
								
								
									
										111
									
								
								src/locales/pt/translation.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/locales/pt/translation.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
{
 | 
			
		||||
  "catalogue": {
 | 
			
		||||
    "featured": "Destaque",
 | 
			
		||||
    "recently_added": "Novidades",
 | 
			
		||||
    "trending": "Populares",
 | 
			
		||||
    "surprise_me": "✨ Me surpreenda"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "header": {
 | 
			
		||||
    "search": "Buscar",
 | 
			
		||||
    "catalogue": "Catálogo",
 | 
			
		||||
    "downloads": "Downloads",
 | 
			
		||||
    "search_results": "Resultados da busca",
 | 
			
		||||
    "settings": "Configurações"
 | 
			
		||||
  },
 | 
			
		||||
  "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}}",
 | 
			
		||||
    "deleting": "Removendo arquivos…"
 | 
			
		||||
  },
 | 
			
		||||
  "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}}",
 | 
			
		||||
    "launch": "Abrir",
 | 
			
		||||
    "resume": "Resumir",
 | 
			
		||||
    "pause": "Pausar",
 | 
			
		||||
    "cancel": "Cancelar",
 | 
			
		||||
    "remove": "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)",
 | 
			
		||||
    "deleting": "Removendo arquivos…",
 | 
			
		||||
    "delete": "Apagar arquivos",
 | 
			
		||||
    "release_date": "Lançado em {{date}}",
 | 
			
		||||
    "publisher": "Publicado por {{publisher}}",
 | 
			
		||||
    "copy_link_to_clipboard": "Copiar link",
 | 
			
		||||
    "copied_link_to_clipboard": "Link copiado"
 | 
			
		||||
  },
 | 
			
		||||
  "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": {
 | 
			
		||||
    "launch": "Abrir",
 | 
			
		||||
    "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…",
 | 
			
		||||
    "deleting": "Removendo arquivos…",
 | 
			
		||||
    "delete": "Apagar arquivos"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  },
 | 
			
		||||
  "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								src/main/constants.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/main/constants.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
import { app } from "electron";
 | 
			
		||||
import os from "node:os";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
export const repackersOn1337x = [
 | 
			
		||||
  "DODI",
 | 
			
		||||
  "FitGirl",
 | 
			
		||||
  "0xEMPRESS",
 | 
			
		||||
  "KaOsKrew",
 | 
			
		||||
  "TinyRepacks",
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const repackers = [
 | 
			
		||||
  ...repackersOn1337x,
 | 
			
		||||
  "Xatab",
 | 
			
		||||
  "CPG",
 | 
			
		||||
  "TinyRepacks",
 | 
			
		||||
  "GOG",
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const months = [
 | 
			
		||||
  "Jan",
 | 
			
		||||
  "Feb",
 | 
			
		||||
  "Mar",
 | 
			
		||||
  "Apr",
 | 
			
		||||
  "May",
 | 
			
		||||
  "Jun",
 | 
			
		||||
  "Jul",
 | 
			
		||||
  "Aug",
 | 
			
		||||
  "Sep",
 | 
			
		||||
  "Oct",
 | 
			
		||||
  "Nov",
 | 
			
		||||
  "Dec",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export enum GameStatus {
 | 
			
		||||
  Seeding = "seeding",
 | 
			
		||||
  Downloading = "downloading",
 | 
			
		||||
  Paused = "paused",
 | 
			
		||||
  CheckingFiles = "checking_files",
 | 
			
		||||
  DownloadingMetadata = "downloading_metadata",
 | 
			
		||||
  Cancelled = "cancelled",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
 | 
			
		||||
 | 
			
		||||
export const databasePath = path.join(
 | 
			
		||||
  app.getPath("appData"),
 | 
			
		||||
  app.getName(),
 | 
			
		||||
  "hydra.db"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const INSTALLATION_ID_LENGTH = 6;
 | 
			
		||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
 | 
			
		||||
							
								
								
									
										33
									
								
								src/main/data-source.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/main/data-source.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import { DataSource } from "typeorm";
 | 
			
		||||
import {
 | 
			
		||||
  Game,
 | 
			
		||||
  GameShopCache,
 | 
			
		||||
  ImageCache,
 | 
			
		||||
  Repack,
 | 
			
		||||
  RepackerFriendlyName,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
  MigrationScript,
 | 
			
		||||
} from "@main/entity";
 | 
			
		||||
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
 | 
			
		||||
 | 
			
		||||
import { databasePath } from "./constants";
 | 
			
		||||
 | 
			
		||||
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
 | 
			
		||||
  new DataSource({
 | 
			
		||||
    type: "sqlite",
 | 
			
		||||
    database: databasePath,
 | 
			
		||||
    entities: [
 | 
			
		||||
      Game,
 | 
			
		||||
      ImageCache,
 | 
			
		||||
      Repack,
 | 
			
		||||
      RepackerFriendlyName,
 | 
			
		||||
      UserPreferences,
 | 
			
		||||
      GameShopCache,
 | 
			
		||||
      MigrationScript,
 | 
			
		||||
    ],
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export const dataSource = createDataSource({
 | 
			
		||||
  synchronize: true,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								src/main/entity/game-shop-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/main/entity/game-shop-cache.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
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")
 | 
			
		||||
  serializedData: string;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  language: string;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								src/main/entity/game.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/main/entity/game.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,60 @@
 | 
			
		|||
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")
 | 
			
		||||
  shop: GameShop;
 | 
			
		||||
 | 
			
		||||
  @Column("text")
 | 
			
		||||
  status: string;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  progress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  fileVerificationProgress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("int", { default: 0 })
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  fileSize: number;
 | 
			
		||||
 | 
			
		||||
  @OneToOne(() => Repack)
 | 
			
		||||
  @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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/main/entity/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/main/entity/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
export * from "./game.entity";
 | 
			
		||||
export * from "./image-cache.entity";
 | 
			
		||||
export * from "./repack.entity";
 | 
			
		||||
export * from "./repacker-friendly-name.entity";
 | 
			
		||||
export * from "./user-preferences.entity";
 | 
			
		||||
export * from "./game-shop-cache.entity";
 | 
			
		||||
export * from "./migration-script.entity";
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
 | 
			
		||||
  @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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								src/main/entity/user-preferences.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/main/entity/user-preferences.entity.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
import {
 | 
			
		||||
  Entity,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Column,
 | 
			
		||||
  CreateDateColumn,
 | 
			
		||||
  UpdateDateColumn,
 | 
			
		||||
} from "typeorm";
 | 
			
		||||
 | 
			
		||||
@Entity("user_preferences")
 | 
			
		||||
export class UserPreferences {
 | 
			
		||||
  @PrimaryGeneratedColumn()
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  downloadsPath: string | null;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { default: "en" })
 | 
			
		||||
  language: string;
 | 
			
		||||
 | 
			
		||||
  @Column("boolean", { default: false })
 | 
			
		||||
  downloadNotificationsEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
  @Column("boolean", { default: false })
 | 
			
		||||
  repackUpdatesNotificationsEnabled: boolean;
 | 
			
		||||
 | 
			
		||||
  @CreateDateColumn()
 | 
			
		||||
  createdAt: Date;
 | 
			
		||||
 | 
			
		||||
  @UpdateDateColumn()
 | 
			
		||||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								src/main/events/catalogue/get-catalogue.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/main/events/catalogue/get-catalogue.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,113 @@
 | 
			
		|||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
 | 
			
		||||
import { getTrendingGames } from "@main/services";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
const repacks = stateManager.getValue("repacks");
 | 
			
		||||
 | 
			
		||||
const getCatalogue = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  category: CatalogueCategory
 | 
			
		||||
) => {
 | 
			
		||||
  const getStringForLookup = (index: number) => {
 | 
			
		||||
    const repack = repacks[index];
 | 
			
		||||
    const formatter =
 | 
			
		||||
      repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
 | 
			
		||||
 | 
			
		||||
    return formatName(formatter(repack.title));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!repacks.length) return [];
 | 
			
		||||
 | 
			
		||||
  const resultSize = 12;
 | 
			
		||||
  const requestSize = resultSize;
 | 
			
		||||
 | 
			
		||||
  if (category === "trending") {
 | 
			
		||||
    return searchTrending(resultSize);
 | 
			
		||||
  } else {
 | 
			
		||||
    return searchRecentlyAdded(resultSize, requestSize, getStringForLookup);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const searchTrending = async (
 | 
			
		||||
  resultSize: number
 | 
			
		||||
): Promise<CatalogueEntry[]> => {
 | 
			
		||||
  const results: CatalogueEntry[] = [];
 | 
			
		||||
  const trendingGames = await getTrendingGames();
 | 
			
		||||
  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),
 | 
			
		||||
      };
 | 
			
		||||
      repacks.sort(
 | 
			
		||||
        (a, b) =>
 | 
			
		||||
          new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
 | 
			
		||||
      );
 | 
			
		||||
      results.push({ ...catalogueEntry, repacks });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return results;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const searchRecentlyAdded = async (
 | 
			
		||||
  resultSize: number,
 | 
			
		||||
  requestSize: number,
 | 
			
		||||
  getStringForLookup: { (index: number): any; (arg0: any): any }
 | 
			
		||||
): 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(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];
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (cachedData) {
 | 
			
		||||
      const cachedDetails = JSON.parse(
 | 
			
		||||
        cachedData.serializedData
 | 
			
		||||
      ) as SteamAppDetails;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        ...cachedDetails,
 | 
			
		||||
        repacks: searchRepacks(cachedDetails.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,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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/shuffle";
 | 
			
		||||
 | 
			
		||||
import { getRandomSteam250List } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchGames, searchRepacks } from "../helpers/search-games";
 | 
			
		||||
import { formatName } from "@main/helpers";
 | 
			
		||||
 | 
			
		||||
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
 | 
			
		||||
  return getRandomSteam250List().then(async (games) => {
 | 
			
		||||
    const shuffledList = shuffle(games);
 | 
			
		||||
 | 
			
		||||
    for (const game of shuffledList) {
 | 
			
		||||
      const repacks = searchRepacks(formatName(game));
 | 
			
		||||
 | 
			
		||||
      if (repacks.length) {
 | 
			
		||||
        const results = await searchGames(game);
 | 
			
		||||
 | 
			
		||||
        if (results.length) {
 | 
			
		||||
          return results[0].objectID;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(getRandomGame, {
 | 
			
		||||
  name: "getRandomGame",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										10
									
								
								src/main/events/catalogue/search-games.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/main/events/catalogue/search-games.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchGames } from "../helpers/search-games";
 | 
			
		||||
 | 
			
		||||
registerEvent(
 | 
			
		||||
  (_event: Electron.IpcMainInvokeEvent, query: string) => searchGames(query),
 | 
			
		||||
  {
 | 
			
		||||
    name: "searchGames",
 | 
			
		||||
    memoize: true,
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										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",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										84
									
								
								src/main/events/helpers/search-games.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/main/events/helpers/search-games.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,84 @@
 | 
			
		|||
import flexSearch from "flexsearch";
 | 
			
		||||
import orderBy from "lodash/orderBy";
 | 
			
		||||
 | 
			
		||||
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
 | 
			
		||||
 | 
			
		||||
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
 | 
			
		||||
import { searchAlgolia } from "@main/services";
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
const { Index } = flexSearch;
 | 
			
		||||
const repacksIndex = new Index();
 | 
			
		||||
 | 
			
		||||
const repacks = stateManager.getValue("repacks");
 | 
			
		||||
 | 
			
		||||
for (let i = 0; i < repacks.length; i++) {
 | 
			
		||||
  const repack = repacks[i];
 | 
			
		||||
  const formatter =
 | 
			
		||||
    repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
 | 
			
		||||
 | 
			
		||||
  repacksIndex.add(i, formatName(formatter(repack.title)));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const HITS_PER_PAGE = 12;
 | 
			
		||||
 | 
			
		||||
export const searchRepacks = (title: string): GameRepack[] => {
 | 
			
		||||
  const repacks = stateManager.getValue("repacks");
 | 
			
		||||
 | 
			
		||||
  return orderBy(
 | 
			
		||||
    repacksIndex
 | 
			
		||||
      .search(formatName(title))
 | 
			
		||||
      .map((index) => repacks.at(index as number)!),
 | 
			
		||||
    ["uploadDate"],
 | 
			
		||||
    "desc"
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const searchGames = async (query: string): Promise<CatalogueEntry[]> => {
 | 
			
		||||
  const formattedName = formatName(query);
 | 
			
		||||
 | 
			
		||||
  const steamResults = await searchAlgolia<{ objectID: string; name: string }>({
 | 
			
		||||
    index: "steamdb",
 | 
			
		||||
    query: formattedName,
 | 
			
		||||
    params: {
 | 
			
		||||
      facetFilters: '["appType:Game"]',
 | 
			
		||||
      hitsPerPage: `${HITS_PER_PAGE}`,
 | 
			
		||||
    },
 | 
			
		||||
    headers: {
 | 
			
		||||
      Referer: "https://steamdb.info/",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const results = steamResults.hits.map((hit) => ({
 | 
			
		||||
    objectID: hit.objectID,
 | 
			
		||||
    title: hit.name,
 | 
			
		||||
    shop: "steam" as GameShop,
 | 
			
		||||
    cover: getSteamAppAsset("library", hit.objectID),
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  const gamesIndex = new Index({
 | 
			
		||||
    tokenize: "full",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  for (let i = 0; i < results.length; i++) {
 | 
			
		||||
    const game = results[i];
 | 
			
		||||
    gamesIndex.add(i, game.title);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const filteredResults = gamesIndex
 | 
			
		||||
    .search(query)
 | 
			
		||||
    .map((index) => results[index as number]);
 | 
			
		||||
 | 
			
		||||
  return Promise.all(
 | 
			
		||||
    filteredResults.map(async (result) => ({
 | 
			
		||||
      ...result,
 | 
			
		||||
      repacks: searchRepacks(result.title),
 | 
			
		||||
    }))
 | 
			
		||||
  ).then((resultsWithRepacks) =>
 | 
			
		||||
    orderBy(
 | 
			
		||||
      resultsWithRepacks,
 | 
			
		||||
      [({ repacks }) => repacks.length, "repacks"],
 | 
			
		||||
      ["desc"]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										26
									
								
								src/main/events/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/main/events/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
import { app, ipcMain } from "electron";
 | 
			
		||||
import { defaultDownloadsPath } from "@main/constants";
 | 
			
		||||
 | 
			
		||||
import "./torrenting/start-game-download";
 | 
			
		||||
import "./catalogue/search-games";
 | 
			
		||||
import "./catalogue/get-game-shop-details";
 | 
			
		||||
import "./catalogue/get-catalogue";
 | 
			
		||||
import "./library/get-library";
 | 
			
		||||
import "./hardware/get-disk-free-space";
 | 
			
		||||
import "./torrenting/cancel-game-download";
 | 
			
		||||
import "./torrenting/pause-game-download";
 | 
			
		||||
import "./torrenting/resume-game-download";
 | 
			
		||||
import "./misc/get-or-cache-image";
 | 
			
		||||
import "./user-preferences/update-user-preferences";
 | 
			
		||||
import "./user-preferences/get-user-preferences";
 | 
			
		||||
import "./library/get-repackers-friendly-names";
 | 
			
		||||
import "./library/get-game-by-object-id";
 | 
			
		||||
import "./library/open-game";
 | 
			
		||||
import "./misc/show-open-dialog";
 | 
			
		||||
import "./library/remove-game";
 | 
			
		||||
import "./library/delete-game-folder";
 | 
			
		||||
import "./catalogue/get-random-game";
 | 
			
		||||
 | 
			
		||||
ipcMain.handle("ping", () => "pong");
 | 
			
		||||
ipcMain.handle("getVersion", () => app.getVersion());
 | 
			
		||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
 | 
			
		||||
							
								
								
									
										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/sortBy";
 | 
			
		||||
 | 
			
		||||
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
 | 
			
		||||
  gameRepository
 | 
			
		||||
    .find({
 | 
			
		||||
      order: {
 | 
			
		||||
        createdAt: "desc",
 | 
			
		||||
      },
 | 
			
		||||
      relations: {
 | 
			
		||||
        repack: true,
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    .then((games) =>
 | 
			
		||||
      sortBy(
 | 
			
		||||
        games.map((game) => ({
 | 
			
		||||
          ...game,
 | 
			
		||||
          repacks: searchRepacks(game.title),
 | 
			
		||||
        })),
 | 
			
		||||
        (game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
registerEvent(getLibrary, {
 | 
			
		||||
  name: "getLibrary",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										38
									
								
								src/main/events/library/open-game.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/main/events/library/open-game.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { shell } from "electron";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
 | 
			
		||||
const openGame = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({ where: { id: gameId } });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
 | 
			
		||||
  const gamePath = path.join(
 | 
			
		||||
    game.downloadPath ?? (await getDownloadsPath()),
 | 
			
		||||
    game.folderName
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (fs.existsSync(gamePath)) {
 | 
			
		||||
    const setupPath = path.join(gamePath, "setup.exe");
 | 
			
		||||
    if (fs.existsSync(setupPath)) {
 | 
			
		||||
      shell.openExternal(setupPath);
 | 
			
		||||
    } else {
 | 
			
		||||
      shell.openPath(gamePath);
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    await gameRepository.delete({
 | 
			
		||||
      id: gameId,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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",
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										52
									
								
								src/main/helpers/formatters.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/main/helpers/formatters.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
/* String formatting */
 | 
			
		||||
 | 
			
		||||
export const removeReleaseYearFromName = (name: string) => name;
 | 
			
		||||
 | 
			
		||||
export const removeSymbolsFromName = (name: string) => name;
 | 
			
		||||
 | 
			
		||||
export const removeSpecialEditionFromName = (name: string) =>
 | 
			
		||||
  name.replace(
 | 
			
		||||
    /(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited) Edition/g,
 | 
			
		||||
    ""
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const removeDuplicateSpaces = (name: string) =>
 | 
			
		||||
  name.replace(/\s{2,}/g, " ");
 | 
			
		||||
 | 
			
		||||
export const removeTrash = (title: string) =>
 | 
			
		||||
  title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
 | 
			
		||||
 | 
			
		||||
/* Formatters per repacker */
 | 
			
		||||
 | 
			
		||||
export const fitGirlFormatter = (title: string) =>
 | 
			
		||||
  title.replace(/\(.*\)/g, "").trim();
 | 
			
		||||
 | 
			
		||||
export const kaosKrewFormatter = (title: string) =>
 | 
			
		||||
  title
 | 
			
		||||
    .replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
 | 
			
		||||
    .replace(
 | 
			
		||||
      /(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
 | 
			
		||||
      ""
 | 
			
		||||
    )
 | 
			
		||||
    .replace(/\./g, " ")
 | 
			
		||||
    .trim();
 | 
			
		||||
 | 
			
		||||
export const empressFormatter = (title: string) =>
 | 
			
		||||
  title
 | 
			
		||||
    .replace(/-EMPRESS/, "")
 | 
			
		||||
    .replace(/\./g, " ")
 | 
			
		||||
    .trim();
 | 
			
		||||
 | 
			
		||||
export const dodiFormatter = (title: string) =>
 | 
			
		||||
  title.replace(/\(.*?\)/g, "").trim();
 | 
			
		||||
 | 
			
		||||
export const xatabFormatter = (title: string) =>
 | 
			
		||||
  title
 | 
			
		||||
    .replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
 | 
			
		||||
    .replace(/[\u0400-\u04FF]/g, "")
 | 
			
		||||
    .replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
 | 
			
		||||
 | 
			
		||||
export const tinyRepacksFormatter = (title: string) => title;
 | 
			
		||||
 | 
			
		||||
export const gogFormatter = (title: string) =>
 | 
			
		||||
  title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");
 | 
			
		||||
							
								
								
									
										82
									
								
								src/main/helpers/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/main/helpers/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,82 @@
 | 
			
		|||
import {
 | 
			
		||||
  removeReleaseYearFromName,
 | 
			
		||||
  removeSymbolsFromName,
 | 
			
		||||
  removeSpecialEditionFromName,
 | 
			
		||||
  empressFormatter,
 | 
			
		||||
  kaosKrewFormatter,
 | 
			
		||||
  fitGirlFormatter,
 | 
			
		||||
  removeDuplicateSpaces,
 | 
			
		||||
  dodiFormatter,
 | 
			
		||||
  removeTrash,
 | 
			
		||||
  xatabFormatter,
 | 
			
		||||
  tinyRepacksFormatter,
 | 
			
		||||
  gogFormatter,
 | 
			
		||||
} from "./formatters";
 | 
			
		||||
import { months, repackers } from "../constants";
 | 
			
		||||
 | 
			
		||||
export const pipe =
 | 
			
		||||
  <T>(...fns: ((arg: T) => any)[]) =>
 | 
			
		||||
  (arg: T) =>
 | 
			
		||||
    fns.reduce((prev, fn) => fn(prev), arg);
 | 
			
		||||
 | 
			
		||||
export const formatName = pipe<string>(
 | 
			
		||||
  removeTrash,
 | 
			
		||||
  removeReleaseYearFromName,
 | 
			
		||||
  removeSymbolsFromName,
 | 
			
		||||
  removeSpecialEditionFromName,
 | 
			
		||||
  removeDuplicateSpaces,
 | 
			
		||||
  (str) => str.trim()
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const repackerFormatter: Record<
 | 
			
		||||
  (typeof repackers)[number],
 | 
			
		||||
  (title: string) => string
 | 
			
		||||
> = {
 | 
			
		||||
  DODI: dodiFormatter,
 | 
			
		||||
  "0xEMPRESS": empressFormatter,
 | 
			
		||||
  KaOsKrew: kaosKrewFormatter,
 | 
			
		||||
  FitGirl: fitGirlFormatter,
 | 
			
		||||
  Xatab: xatabFormatter,
 | 
			
		||||
  CPG: (title: string) => title,
 | 
			
		||||
  TinyRepacks: tinyRepacksFormatter,
 | 
			
		||||
  GOG: gogFormatter,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const formatUploadDate = (str: string) => {
 | 
			
		||||
  const date = new Date();
 | 
			
		||||
 | 
			
		||||
  const [month, day, year] = str.split(" ");
 | 
			
		||||
 | 
			
		||||
  date.setMonth(months.indexOf(month.replace(".", "")));
 | 
			
		||||
  date.setDate(Number(day.substring(0, 2)));
 | 
			
		||||
  date.setFullYear(Number("20" + year.replace("'", "")));
 | 
			
		||||
  date.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
  return date;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamAppAsset = (
 | 
			
		||||
  category: "library" | "hero" | "logo" | "icon",
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  clientIcon?: string
 | 
			
		||||
) => {
 | 
			
		||||
  if (category === "library")
 | 
			
		||||
    return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
 | 
			
		||||
 | 
			
		||||
  if (category === "hero")
 | 
			
		||||
    return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
 | 
			
		||||
 | 
			
		||||
  if (category === "logo")
 | 
			
		||||
    return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
 | 
			
		||||
 | 
			
		||||
  return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getImageBase64 = async (url: string) =>
 | 
			
		||||
  fetch(url, { method: "GET" }).then((response) =>
 | 
			
		||||
    response.arrayBuffer().then((buffer) => {
 | 
			
		||||
      return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export * from "./formatters";
 | 
			
		||||
							
								
								
									
										115
									
								
								src/main/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/main/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,115 @@
 | 
			
		|||
import { stateManager } from "./state-manager";
 | 
			
		||||
import { GameStatus, repackers } from "./constants";
 | 
			
		||||
import {
 | 
			
		||||
  getNewGOGGames,
 | 
			
		||||
  getNewRepacksFromCPG,
 | 
			
		||||
  getNewRepacksFromUser,
 | 
			
		||||
  getNewRepacksFromXatab,
 | 
			
		||||
  readPipe,
 | 
			
		||||
  writePipe,
 | 
			
		||||
} from "./services";
 | 
			
		||||
import {
 | 
			
		||||
  gameRepository,
 | 
			
		||||
  repackRepository,
 | 
			
		||||
  repackerFriendlyNameRepository,
 | 
			
		||||
  userPreferencesRepository,
 | 
			
		||||
} from "./repository";
 | 
			
		||||
import { TorrentClient } from "./services/torrent-client";
 | 
			
		||||
import { Repack } from "./entity";
 | 
			
		||||
import { Notification } from "electron";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
 | 
			
		||||
 | 
			
		||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
    relations: { repack: true },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (game) {
 | 
			
		||||
    writePipe.write({
 | 
			
		||||
      action: "start",
 | 
			
		||||
      game_id: game.id,
 | 
			
		||||
      magnet: game.repack.magnet,
 | 
			
		||||
      save_path: game.downloadPath,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  readPipe.socket.on("data", (data) => {
 | 
			
		||||
    TorrentClient.onSocketData(data);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const track1337xUsers = async (existingRepacks: Repack[]) => {
 | 
			
		||||
  for (const repacker of repackers) {
 | 
			
		||||
    await getNewRepacksFromUser(
 | 
			
		||||
      repacker,
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === repacker)
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const checkForNewRepacks = async () => {
 | 
			
		||||
  const userPreferences = await userPreferencesRepository.findOne({
 | 
			
		||||
    where: { id: 1 },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const existingRepacks = stateManager.getValue("repacks");
 | 
			
		||||
 | 
			
		||||
  Promise.allSettled([
 | 
			
		||||
    getNewGOGGames(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "GOG")
 | 
			
		||||
    ),
 | 
			
		||||
    getNewRepacksFromXatab(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "Xatab")
 | 
			
		||||
    ),
 | 
			
		||||
    getNewRepacksFromCPG(
 | 
			
		||||
      existingRepacks.filter((repack) => repack.repacker === "CPG")
 | 
			
		||||
    ),
 | 
			
		||||
    track1337xUsers(existingRepacks),
 | 
			
		||||
  ]).then(() => {
 | 
			
		||||
    repackRepository.count().then((count) => {
 | 
			
		||||
      const total = count - stateManager.getValue("repacks").length;
 | 
			
		||||
 | 
			
		||||
      if (total > 0 && userPreferences.repackUpdatesNotificationsEnabled) {
 | 
			
		||||
        new Notification({
 | 
			
		||||
          title: t("repack_list_updated", {
 | 
			
		||||
            ns: "notifications",
 | 
			
		||||
            lng: userPreferences?.language || "en",
 | 
			
		||||
          }),
 | 
			
		||||
          body: t("repack_count", {
 | 
			
		||||
            ns: "notifications",
 | 
			
		||||
            lng: userPreferences?.language || "en",
 | 
			
		||||
            count: total,
 | 
			
		||||
          }),
 | 
			
		||||
        }).show();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const loadState = async () => {
 | 
			
		||||
  const [friendlyNames, repacks] = await Promise.all([
 | 
			
		||||
    repackerFriendlyNameRepository.find(),
 | 
			
		||||
    repackRepository.find({
 | 
			
		||||
      order: {
 | 
			
		||||
        createdAt: "desc",
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  stateManager.setValue("repackersFriendlyNames", friendlyNames);
 | 
			
		||||
  stateManager.setValue("repacks", repacks);
 | 
			
		||||
 | 
			
		||||
  import("./events");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
loadState().then(() => checkForNewRepacks());
 | 
			
		||||
							
								
								
									
										27
									
								
								src/main/repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/main/repository.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import { dataSource } from "./data-source";
 | 
			
		||||
import {
 | 
			
		||||
  Game,
 | 
			
		||||
  GameShopCache,
 | 
			
		||||
  ImageCache,
 | 
			
		||||
  Repack,
 | 
			
		||||
  RepackerFriendlyName,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
  MigrationScript,
 | 
			
		||||
} from "@main/entity";
 | 
			
		||||
 | 
			
		||||
export const gameRepository = dataSource.getRepository(Game);
 | 
			
		||||
 | 
			
		||||
export const imageCacheRepository = dataSource.getRepository(ImageCache);
 | 
			
		||||
 | 
			
		||||
export const repackRepository = dataSource.getRepository(Repack);
 | 
			
		||||
 | 
			
		||||
export const repackerFriendlyNameRepository =
 | 
			
		||||
  dataSource.getRepository(RepackerFriendlyName);
 | 
			
		||||
 | 
			
		||||
export const userPreferencesRepository =
 | 
			
		||||
  dataSource.getRepository(UserPreferences);
 | 
			
		||||
 | 
			
		||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
 | 
			
		||||
 | 
			
		||||
export const migrationScriptRepository =
 | 
			
		||||
  dataSource.getRepository(MigrationScript);
 | 
			
		||||
							
								
								
									
										54
									
								
								src/main/services/algolia.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/main/services/algolia.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
import axios, { RawAxiosRequestHeaders } from "axios";
 | 
			
		||||
import { requestWebPage } from "./repack-tracker/helpers";
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
export interface AlgoliaResponse<T> {
 | 
			
		||||
  hits: T[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AlgoliaSearchParams {
 | 
			
		||||
  index: string;
 | 
			
		||||
  query: string;
 | 
			
		||||
  params?: Record<string, string>;
 | 
			
		||||
  headers?: RawAxiosRequestHeaders;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getSteamDBAlgoliaCredentials = async () => {
 | 
			
		||||
  const js = await requestWebPage(
 | 
			
		||||
    "https://steamdb.info/static/js/instantsearch.js"
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const algoliaCredentialsRegExp = new RegExp(
 | 
			
		||||
    /algoliasearch\("(.*?)","(.*?)"\);/
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [, applicationId, apiKey] = algoliaCredentialsRegExp.exec(js);
 | 
			
		||||
 | 
			
		||||
  return { applicationId, apiKey };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const searchAlgolia = async <T>(
 | 
			
		||||
  params: AlgoliaSearchParams
 | 
			
		||||
): Promise<AlgoliaResponse<T>> => {
 | 
			
		||||
  const algoliaCredentials = stateManager.getValue("steamDBAlgoliaCredentials");
 | 
			
		||||
 | 
			
		||||
  const searchParams = new URLSearchParams({
 | 
			
		||||
    "x-algolia-agent":
 | 
			
		||||
      "Algolia for JavaScript (4.13.1); Browser (lite); JS Helper (3.9.0); react (18.1.0); react-instantsearch (6.29.0)",
 | 
			
		||||
    "x-algolia-application-id": algoliaCredentials.applicationId,
 | 
			
		||||
    "x-algolia-api-key": algoliaCredentials.apiKey,
 | 
			
		||||
    query: params.query,
 | 
			
		||||
    ...params.params,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return axios
 | 
			
		||||
    .get(
 | 
			
		||||
      `https://${algoliaCredentials.applicationId.toLowerCase()}-dsn.algolia.net/1/indexes/${
 | 
			
		||||
        params.index
 | 
			
		||||
      }?${searchParams.toString()}`,
 | 
			
		||||
      {
 | 
			
		||||
        headers: params.headers,
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
    .then((response) => response.data);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										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();
 | 
			
		||||
							
								
								
									
										10
									
								
								src/main/services/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/main/services/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
export * from "./algolia";
 | 
			
		||||
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";
 | 
			
		||||
							
								
								
									
										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" }),
 | 
			
		||||
  ],
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										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());
 | 
			
		||||
							
								
								
									
										4
									
								
								src/main/services/repack-tracker/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/main/services/repack-tracker/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export * from "./1337x";
 | 
			
		||||
export * from "./xatab";
 | 
			
		||||
export * from "./cpg-repacks";
 | 
			
		||||
export * from "./gog";
 | 
			
		||||
							
								
								
									
										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);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										53
									
								
								src/main/services/steam-250.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/main/services/steam-250.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
import axios from "axios";
 | 
			
		||||
import { JSDOM } from "jsdom";
 | 
			
		||||
import shuffle from "lodash/shuffle";
 | 
			
		||||
import { logger } from "./logger";
 | 
			
		||||
 | 
			
		||||
const requestSteam250 = async (path: string) => {
 | 
			
		||||
  return axios
 | 
			
		||||
    .get(`https://steam250.com${path}`)
 | 
			
		||||
    .then((response) => response.data);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getTrendingGames = async () => {
 | 
			
		||||
  const response = await requestSteam250("/365day").catch((err) => {
 | 
			
		||||
    logger.error(err.response, { method: "getTrendingGames" });
 | 
			
		||||
    throw new Error(err);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
  const { document } = window;
 | 
			
		||||
 | 
			
		||||
  return Array.from(document.querySelectorAll(".appline .title a")).map(
 | 
			
		||||
    ($title: HTMLAnchorElement) => {
 | 
			
		||||
      const steamGameUrld = $title.href;
 | 
			
		||||
      if (!steamGameUrld) return null;
 | 
			
		||||
      return {
 | 
			
		||||
        title: $title.textContent,
 | 
			
		||||
        objectID: steamGameUrld.split("/").pop(),
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const steam250Paths = [
 | 
			
		||||
  "/hidden_gems",
 | 
			
		||||
  `/${new Date().getFullYear()}`,
 | 
			
		||||
  "/top250",
 | 
			
		||||
  "/most_played",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const getRandomSteam250List = async () => {
 | 
			
		||||
  const [path] = shuffle(steam250Paths);
 | 
			
		||||
  const response = await requestSteam250(path).catch((err) => {
 | 
			
		||||
    logger.error(err.response, { method: "getRandomSteam250List" });
 | 
			
		||||
    throw new Error(err);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const { window } = new JSDOM(response);
 | 
			
		||||
  const { document } = window;
 | 
			
		||||
 | 
			
		||||
  return Array.from(document.querySelectorAll(".appline .title a")).map(
 | 
			
		||||
    ($title) => $title.textContent!
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										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 ${process.env.STEAMGRIDDB_API_KEY}`,
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return response.json();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamGridGameById = async (
 | 
			
		||||
  id: number
 | 
			
		||||
): Promise<SteamGridGameResponse> => {
 | 
			
		||||
  const response = await fetch(
 | 
			
		||||
    `https://www.steamgriddb.com/api/public/game/${id}`,
 | 
			
		||||
    {
 | 
			
		||||
      method: "GET",
 | 
			
		||||
      headers: {
 | 
			
		||||
        Referer: "https://www.steamgriddb.com/",
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return response.json();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getSteamGameIconUrl = async (objectID: string) => {
 | 
			
		||||
  const {
 | 
			
		||||
    data: { id: steamGridGameId },
 | 
			
		||||
  } = await getSteamGridData(objectID, "games", "steam");
 | 
			
		||||
 | 
			
		||||
  const steamGridGame = await getSteamGridGameById(steamGridGameId);
 | 
			
		||||
 | 
			
		||||
  return getSteamAppAsset(
 | 
			
		||||
    "icon",
 | 
			
		||||
    objectID,
 | 
			
		||||
    steamGridGame.data.platforms.steam.metadata.clienticon
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										33
									
								
								src/main/services/steam.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/main/services/steam.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import type { SteamAppDetails } from "@types";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
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);
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										151
									
								
								src/main/services/torrent-client.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/main/services/torrent-client.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,151 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import cp from "node:child_process";
 | 
			
		||||
import { Notification, app } from "electron";
 | 
			
		||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
 | 
			
		||||
 | 
			
		||||
import { Game } from "@main/entity";
 | 
			
		||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import { WindowManager } from "./window-manager";
 | 
			
		||||
 | 
			
		||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
 | 
			
		||||
  darwin: "hydra-download-manager",
 | 
			
		||||
  linux: "hydra-download-manager",
 | 
			
		||||
  win32: "hydra-download-manager.exe",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
enum TorrentState {
 | 
			
		||||
  CheckingFiles = 1,
 | 
			
		||||
  DownloadingMetadata = 2,
 | 
			
		||||
  Downloading = 3,
 | 
			
		||||
  Finished = 4,
 | 
			
		||||
  Seeding = 5,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface TorrentUpdate {
 | 
			
		||||
  gameId: number;
 | 
			
		||||
  progress: number;
 | 
			
		||||
  downloadSpeed: number;
 | 
			
		||||
  timeRemaining: number;
 | 
			
		||||
  numPeers: number;
 | 
			
		||||
  numSeeds: number;
 | 
			
		||||
  status: TorrentState;
 | 
			
		||||
  folderName: string;
 | 
			
		||||
  fileSize: number;
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class TorrentClient {
 | 
			
		||||
  public static startTorrentClient(
 | 
			
		||||
    writePipePath: string,
 | 
			
		||||
    readPipePath: string
 | 
			
		||||
  ) {
 | 
			
		||||
    const commonArgs = ["6881", writePipePath, readPipePath];
 | 
			
		||||
 | 
			
		||||
    if (app.isPackaged) {
 | 
			
		||||
      const binaryName = binaryNameByPlatform[process.platform];
 | 
			
		||||
      const binaryPath = path.join(
 | 
			
		||||
        process.resourcesPath,
 | 
			
		||||
        "dist",
 | 
			
		||||
        "hydra-download-manager",
 | 
			
		||||
        binaryName
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      cp.spawn(binaryPath, commonArgs, {
 | 
			
		||||
        stdio: "inherit",
 | 
			
		||||
        windowsHide: true,
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const scriptPath = path.join(
 | 
			
		||||
      __dirname,
 | 
			
		||||
      "..",
 | 
			
		||||
      "..",
 | 
			
		||||
      "torrent-client",
 | 
			
		||||
      "main.py"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    cp.spawn("python3", [scriptPath, ...commonArgs], {
 | 
			
		||||
      stdio: "inherit",
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static getTorrentStateName(state: TorrentState) {
 | 
			
		||||
    if (state === TorrentState.CheckingFiles) return "checking_files";
 | 
			
		||||
    if (state === TorrentState.Downloading) return "downloading";
 | 
			
		||||
    if (state === TorrentState.DownloadingMetadata)
 | 
			
		||||
      return "downloading_metadata";
 | 
			
		||||
    if (state === TorrentState.Finished) return "finished";
 | 
			
		||||
    if (state === TorrentState.Seeding) return "seeding";
 | 
			
		||||
    return "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static getGameProgress(game: Game) {
 | 
			
		||||
    if (game.status === "checking_files") return game.fileVerificationProgress;
 | 
			
		||||
    return game.progress;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static async onSocketData(data: Buffer) {
 | 
			
		||||
    const payload = JSON.parse(
 | 
			
		||||
      Buffer.from(data).toString("utf-8")
 | 
			
		||||
    ) 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 }))
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										87
									
								
								src/main/services/update-resolver.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/main/services/update-resolver.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import { app } from "electron";
 | 
			
		||||
 | 
			
		||||
import chunk from "lodash/chunk";
 | 
			
		||||
 | 
			
		||||
import { createDataSource, dataSource } from "@main/data-source";
 | 
			
		||||
import { Repack, RepackerFriendlyName } from "@main/entity";
 | 
			
		||||
import {
 | 
			
		||||
  migrationScriptRepository,
 | 
			
		||||
  repackRepository,
 | 
			
		||||
  repackerFriendlyNameRepository,
 | 
			
		||||
} from "@main/repository";
 | 
			
		||||
import { MigrationScript } from "@main/entity/migration-script.entity";
 | 
			
		||||
 | 
			
		||||
export const resolveDatabaseUpdates = async () => {
 | 
			
		||||
  const updateDataSource = createDataSource({
 | 
			
		||||
    database: app.isPackaged
 | 
			
		||||
      ? path.join(process.resourcesPath, "hydra.db")
 | 
			
		||||
      : path.join(__dirname, "..", "..", "resources", "hydra.db"),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return updateDataSource.initialize().then(async () => {
 | 
			
		||||
    const updateRepackRepository = updateDataSource.getRepository(Repack);
 | 
			
		||||
    const updateRepackerFriendlyNameRepository =
 | 
			
		||||
      updateDataSource.getRepository(RepackerFriendlyName);
 | 
			
		||||
 | 
			
		||||
    const [updateRepacks, updateRepackerFriendlyNames] = await Promise.all([
 | 
			
		||||
      updateRepackRepository.find(),
 | 
			
		||||
      updateRepackerFriendlyNameRepository.find(),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    /*
 | 
			
		||||
      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.
 | 
			
		||||
    */
 | 
			
		||||
    const migrationScript = await migrationScriptRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        version: "0.0.7",
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    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: "0.0.7",
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										105
									
								
								src/main/services/window-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/main/services/window-manager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,105 @@
 | 
			
		|||
import { BrowserWindow, Menu, Tray, app } from "electron";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
 | 
			
		||||
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
 | 
			
		||||
// whether you're running in development or production).
 | 
			
		||||
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
 | 
			
		||||
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
 | 
			
		||||
 | 
			
		||||
export class WindowManager {
 | 
			
		||||
  public static mainWindow: Electron.BrowserWindow | null = null;
 | 
			
		||||
 | 
			
		||||
  public static createMainWindow() {
 | 
			
		||||
    // Create the browser window.
 | 
			
		||||
    this.mainWindow = new BrowserWindow({
 | 
			
		||||
      width: 1200,
 | 
			
		||||
      height: 720,
 | 
			
		||||
      titleBarStyle: "hidden",
 | 
			
		||||
      icon: path.join(__dirname, "..", "..", "images", "icon.png"),
 | 
			
		||||
      trafficLightPosition: { x: 16, y: 16 },
 | 
			
		||||
      titleBarOverlay: {
 | 
			
		||||
        symbolColor: "#DADBE1",
 | 
			
		||||
        color: "#151515",
 | 
			
		||||
        height: 34,
 | 
			
		||||
      },
 | 
			
		||||
      webPreferences: {
 | 
			
		||||
        preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.removeMenu();
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.webContents.on("did-finish-load", () => {
 | 
			
		||||
      if (!app.isPackaged) {
 | 
			
		||||
        // Open the DevTools.
 | 
			
		||||
        this.mainWindow.webContents.openDevTools();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.mainWindow.on("close", () => {
 | 
			
		||||
      WindowManager.mainWindow.setProgressBar(-1);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static redirect(path: string) {
 | 
			
		||||
    if (!this.mainWindow) this.createMainWindow();
 | 
			
		||||
    this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
 | 
			
		||||
 | 
			
		||||
    if (this.mainWindow.isMinimized()) this.mainWindow.restore();
 | 
			
		||||
    this.mainWindow.focus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static createSystemTray(language: string) {
 | 
			
		||||
    const tray = new Tray(
 | 
			
		||||
      app.isPackaged
 | 
			
		||||
        ? path.join(process.resourcesPath, "icon_tray.png")
 | 
			
		||||
        : path.join(__dirname, "..", "..", "resources", "icon_tray.png")
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const contextMenu = Menu.buildFromTemplate([
 | 
			
		||||
      {
 | 
			
		||||
        label: t("open", {
 | 
			
		||||
          ns: "system_tray",
 | 
			
		||||
          lng: language,
 | 
			
		||||
        }),
 | 
			
		||||
        type: "normal",
 | 
			
		||||
        click: () => {
 | 
			
		||||
          if (this.mainWindow) {
 | 
			
		||||
            this.mainWindow.show();
 | 
			
		||||
          } else {
 | 
			
		||||
            this.createMainWindow();
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        label: t("quit", {
 | 
			
		||||
          ns: "system_tray",
 | 
			
		||||
          lng: language,
 | 
			
		||||
        }),
 | 
			
		||||
        type: "normal",
 | 
			
		||||
        click: () => app.quit(),
 | 
			
		||||
      },
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    tray.setToolTip("Hydra");
 | 
			
		||||
    tray.setContextMenu(contextMenu);
 | 
			
		||||
 | 
			
		||||
    if (process.platform === "win32") {
 | 
			
		||||
      tray.addListener("click", () => {
 | 
			
		||||
        if (this.mainWindow) {
 | 
			
		||||
          if (WindowManager.mainWindow.isMinimized())
 | 
			
		||||
            WindowManager.mainWindow.restore();
 | 
			
		||||
 | 
			
		||||
          WindowManager.mainWindow.focus();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.createMainWindow();
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								src/main/state-manager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/main/state-manager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
import type { Repack, RepackerFriendlyName } from "@main/entity";
 | 
			
		||||
 | 
			
		||||
interface State {
 | 
			
		||||
  repacks: Repack[];
 | 
			
		||||
  repackersFriendlyNames: RepackerFriendlyName[];
 | 
			
		||||
  eventResults: Map<[string, any[]], any>;
 | 
			
		||||
  steamDBAlgoliaCredentials: {
 | 
			
		||||
    applicationId: string;
 | 
			
		||||
    apiKey: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const initialState: State = {
 | 
			
		||||
  repacks: [],
 | 
			
		||||
  repackersFriendlyNames: [],
 | 
			
		||||
  eventResults: new Map(),
 | 
			
		||||
  steamDBAlgoliaCredentials: {
 | 
			
		||||
    applicationId: "",
 | 
			
		||||
    apiKey: "",
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
							
								
								
									
										70
									
								
								src/preload.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/preload.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,70 @@
 | 
			
		|||
// 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: (callback: (value: TorrentProgress) => void) => {
 | 
			
		||||
    const listener = (
 | 
			
		||||
      _event: Electron.IpcRendererEvent,
 | 
			
		||||
      value: TorrentProgress
 | 
			
		||||
    ) => callback(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"),
 | 
			
		||||
 | 
			
		||||
  /* User preferences */
 | 
			
		||||
  getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
 | 
			
		||||
  updateUserPreferences: (preferences: UserPreferences) =>
 | 
			
		||||
    ipcRenderer.invoke("updateUserPreferences", preferences),
 | 
			
		||||
 | 
			
		||||
  /* Library */
 | 
			
		||||
  getLibrary: () => ipcRenderer.invoke("getLibrary"),
 | 
			
		||||
  getRepackersFriendlyNames: () =>
 | 
			
		||||
    ipcRenderer.invoke("getRepackersFriendlyNames"),
 | 
			
		||||
  openGame: (gameId: number) => ipcRenderer.invoke("openGame", gameId),
 | 
			
		||||
  removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
 | 
			
		||||
  deleteGameFolder: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("deleteGameFolder", gameId),
 | 
			
		||||
  getGameByObjectID: (objectID: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameByObjectID", objectID),
 | 
			
		||||
 | 
			
		||||
  /* 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"),
 | 
			
		||||
  showOpenDialog: (options: Electron.OpenDialogOptions) =>
 | 
			
		||||
    ipcRenderer.invoke("showOpenDialog", options),
 | 
			
		||||
  platform: process.platform,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										29
									
								
								src/renderer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/renderer.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
/**
 | 
			
		||||
 * This file will automatically be loaded by vite and run in the "renderer" context.
 | 
			
		||||
 * To learn more about the differences between the "main" and the "renderer" context in
 | 
			
		||||
 * Electron, visit:
 | 
			
		||||
 *
 | 
			
		||||
 * https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes
 | 
			
		||||
 *
 | 
			
		||||
 * By default, Node.js integration in this file is disabled. When enabling Node.js integration
 | 
			
		||||
 * in a renderer process, please be aware of potential security implications. You can read
 | 
			
		||||
 * more about security risks here:
 | 
			
		||||
 *
 | 
			
		||||
 * https://electronjs.org/docs/tutorial/security
 | 
			
		||||
 *
 | 
			
		||||
 * To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration`
 | 
			
		||||
 * flag:
 | 
			
		||||
 *
 | 
			
		||||
 * ```
 | 
			
		||||
 *  // Create the browser window.
 | 
			
		||||
 *  mainWindow = new BrowserWindow({
 | 
			
		||||
 *    width: 800,
 | 
			
		||||
 *    height: 600,
 | 
			
		||||
 *    webPreferences: {
 | 
			
		||||
 *      nodeIntegration: true
 | 
			
		||||
 *    }
 | 
			
		||||
 *  });
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import "./renderer/main";
 | 
			
		||||
							
								
								
									
										107
									
								
								src/renderer/app.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/renderer/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: "8px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar-track", {
 | 
			
		||||
  backgroundColor: "rgba(0, 0, 0, 0.1)",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar-thumb", {
 | 
			
		||||
  backgroundColor: "rgba(0, 0, 0, 0.2)",
 | 
			
		||||
  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);
 | 
			
		||||
							
								
								
									
										132
									
								
								src/renderer/app.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/renderer/app.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,132 @@
 | 
			
		|||
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 debounce from "lodash/debounce";
 | 
			
		||||
import type { DebouncedFunc } from "lodash";
 | 
			
		||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import {
 | 
			
		||||
  setSearch,
 | 
			
		||||
  clearSearch,
 | 
			
		||||
  setUserPreferences,
 | 
			
		||||
  setRepackersFriendlyNames,
 | 
			
		||||
  setSearchResults,
 | 
			
		||||
} 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 debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
 | 
			
		||||
 | 
			
		||||
  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 (debouncedFunc.current) debouncedFunc.current.cancel();
 | 
			
		||||
 | 
			
		||||
      if (query === "") {
 | 
			
		||||
        navigate(-1);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (location.pathname !== "/search") {
 | 
			
		||||
        navigate("/search");
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      debouncedFunc.current = debounce(() => {
 | 
			
		||||
        window.electron.searchGames(query).then((results) => {
 | 
			
		||||
          dispatch(setSearchResults(results));
 | 
			
		||||
        });
 | 
			
		||||
      }, 300);
 | 
			
		||||
 | 
			
		||||
      debouncedFunc.current();
 | 
			
		||||
    },
 | 
			
		||||
    [dispatch, location.pathname, navigate]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleClear = useCallback(() => {
 | 
			
		||||
    dispatch(clearSearch());
 | 
			
		||||
    navigate(-1);
 | 
			
		||||
  }, [dispatch, navigate]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (contentRef.current) contentRef.current.scrollTop = 0;
 | 
			
		||||
  }, [location.pathname]);
 | 
			
		||||
 | 
			
		||||
  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 />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/renderer/assets/epic-games-logo.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/renderer/assets/epic-games-logo.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4.261 4.261 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 0 1 .591.108a1.745 1.745 0 0 1 .49.299l-.452.546a1.247 1.247 0 0 0-.308-.195a.91.91 0 0 0-.363-.068a.658.658 0 0 0-.28.06a.703.703 0 0 0-.224.163a.783.783 0 0 0-.151.243a.799.799 0 0 0-.056.299v.008a.852.852 0 0 0 .056.31a.7.7 0 0 0 .157.245a.736.736 0 0 0 .238.16a.774.774 0 0 0 .303.058a.79.79 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 0 1-.524.307a1.789 1.789 0 0 1-.683.123a1.642 1.642 0 0 1-.602-.107a1.46 1.46 0 0 1-.478-.3a1.371 1.371 0 0 1-.318-.455a1.438 1.438 0 0 1-.115-.58v-.008a1.426 1.426 0 0 1 .113-.57a1.449 1.449 0 0 1 .312-.46a1.418 1.418 0 0 1 .474-.309a1.58 1.58 0 0 1 .598-.111a1.708 1.708 0 0 1 .045 0zm11.963.008a2.006 2.006 0 0 1 .612.094a1.61 1.61 0 0 1 .507.277l-.386.546a1.562 1.562 0 0 0-.39-.205a1.178 1.178 0 0 0-.388-.07a.347.347 0 0 0-.208.052a.154.154 0 0 0-.07.127v.008a.158.158 0 0 0 .022.084a.198.198 0 0 0 .076.066a.831.831 0 0 0 .147.06c.062.02.14.04.236.061a3.389 3.389 0 0 1 .43.122a1.292 1.292 0 0 1 .328.17a.678.678 0 0 1 .207.24a.739.739 0 0 1 .071.337v.008a.865.865 0 0 1-.081.382a.82.82 0 0 1-.229.285a1.032 1.032 0 0 1-.353.18a1.606 1.606 0 0 1-.46.061a2.16 2.16 0 0 1-.71-.116a1.718 1.718 0 0 1-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 0 0 .236-.05a.157.157 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.204.204 0 0 0-.073-.066a.753.753 0 0 0-.143-.062a2.45 2.45 0 0 0-.233-.062a5.036 5.036 0 0 1-.413-.113a1.26 1.26 0 0 1-.331-.16a.72.72 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.863.863 0 0 1 .074-.359a.794.794 0 0 1 .214-.283a1.007 1.007 0 0 1 .34-.185a1.423 1.423 0 0 1 .448-.066a2.006 2.006 0 0 1 .025 0m-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 2.9 KiB  | 
							
								
								
									
										1
									
								
								src/renderer/assets/steam-logo.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/renderer/assets/steam-logo.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10c-4.6 0-8.45-3.08-9.64-7.27l3.83 1.58a2.843 2.843 0 0 0 2.78 2.27c1.56 0 2.83-1.27 2.83-2.83v-.13l3.4-2.43h.08c2.08 0 3.77-1.69 3.77-3.77s-1.69-3.77-3.77-3.77s-3.78 1.69-3.78 3.77v.05l-2.37 3.46l-.16-.01c-.59 0-1.14.18-1.59.49L2 11.2C2.43 6.05 6.73 2 12 2M8.28 17.17c.8.33 1.72-.04 2.05-.84c.33-.8-.05-1.71-.83-2.04l-1.28-.53c.49-.18 1.04-.19 1.56.03c.53.21.94.62 1.15 1.15c.22.52.22 1.1 0 1.62c-.43 1.08-1.7 1.6-2.78 1.15c-.5-.21-.88-.59-1.09-1.04zm9.52-7.75c0 1.39-1.13 2.52-2.52 2.52a2.52 2.52 0 0 1-2.51-2.52a2.5 2.5 0 0 1 2.51-2.51a2.52 2.52 0 0 1 2.52 2.51m-4.4 0c0 1.04.84 1.89 1.89 1.89c1.04 0 1.88-.85 1.88-1.89s-.84-1.89-1.88-1.89c-1.05 0-1.89.85-1.89 1.89"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 828 B  | 
							
								
								
									
										27
									
								
								src/renderer/components/async-image/async-image.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/renderer/components/async-image/async-image.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import { forwardRef, useEffect, useState } from "react";
 | 
			
		||||
 | 
			
		||||
export interface AsyncImageProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ImgHTMLAttributes<HTMLImageElement>,
 | 
			
		||||
    HTMLImageElement
 | 
			
		||||
  > {
 | 
			
		||||
  onSettled?: (url: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AsyncImage = forwardRef<HTMLImageElement, AsyncImageProps>(
 | 
			
		||||
  ({ onSettled, ...props }, ref) => {
 | 
			
		||||
    const [source, setSource] = useState<string | null>(null);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (props.src && props.src.startsWith("http")) {
 | 
			
		||||
        window.electron.getOrCacheImage(props.src).then((url) => {
 | 
			
		||||
          setSource(url);
 | 
			
		||||
 | 
			
		||||
          if (onSettled) onSettled(url);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }, [props.src, onSettled]);
 | 
			
		||||
 | 
			
		||||
    return <img ref={ref} {...props} src={source ?? props.src} />;
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										21
									
								
								src/renderer/components/bottom-panel/bottom-panel.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/renderer/components/bottom-panel/bottom-panel.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
export const bottomPanel = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  borderTop: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  justifyContent: "space-between",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadsButton = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    textDecoration: "underline",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										68
									
								
								src/renderer/components/bottom-panel/bottom-panel.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/renderer/components/bottom-panel/bottom-panel.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,68 @@
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { useDownload } from "@renderer/hooks";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./bottom-panel.css";
 | 
			
		||||
import { vars } from "@renderer/theme.css";
 | 
			
		||||
import { useEffect, useMemo, useState } from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { VERSION_CODENAME } from "@renderer/constants";
 | 
			
		||||
 | 
			
		||||
export function BottomPanel() {
 | 
			
		||||
  const { t } = useTranslation("bottom_panel");
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const { game, progress, downloadSpeed, eta, isDownloading } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const [version, setVersion] = useState("");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.electron.getVersion().then((result) => setVersion(result));
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const status = useMemo(() => {
 | 
			
		||||
    if (isDownloading) {
 | 
			
		||||
      if (game.status === "downloading_metadata")
 | 
			
		||||
        return t("downloading_metadata", { title: game.title });
 | 
			
		||||
 | 
			
		||||
      if (game.status === "checking_files")
 | 
			
		||||
        return t("checking_files", {
 | 
			
		||||
          title: game.title,
 | 
			
		||||
          percentage: progress,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      return t("downloading", {
 | 
			
		||||
        title: game?.title,
 | 
			
		||||
        percentage: progress,
 | 
			
		||||
        eta,
 | 
			
		||||
        speed: downloadSpeed,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return t("no_downloads_in_progress");
 | 
			
		||||
  }, [t, game, progress, eta, isDownloading, downloadSpeed]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <footer
 | 
			
		||||
      className={styles.bottomPanel}
 | 
			
		||||
      style={{
 | 
			
		||||
        background: isDownloading
 | 
			
		||||
          ? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
 | 
			
		||||
          : vars.color.darkBackground,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        className={styles.downloadsButton}
 | 
			
		||||
        onClick={() => navigate("/downloads")}
 | 
			
		||||
      >
 | 
			
		||||
        <small>{status}</small>
 | 
			
		||||
      </button>
 | 
			
		||||
 | 
			
		||||
      <small>
 | 
			
		||||
        v{version} "{VERSION_CODENAME}"
 | 
			
		||||
      </small>
 | 
			
		||||
    </footer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								src/renderer/components/button/button.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/renderer/components/button/button.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
import { style, styleVariants } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
const base = style({
 | 
			
		||||
  padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  backgroundColor: "#c0c1c7",
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  border: "solid 1px transparent",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  minHeight: "40px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  ":active": {
 | 
			
		||||
    opacity: vars.opacity.active,
 | 
			
		||||
  },
 | 
			
		||||
  ":disabled": {
 | 
			
		||||
    opacity: vars.opacity.disabled,
 | 
			
		||||
    pointerEvents: "none",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const button = styleVariants({
 | 
			
		||||
  primary: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      ":hover": {
 | 
			
		||||
        backgroundColor: "#DADBE1",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  outline: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: "transparent",
 | 
			
		||||
      border: "solid 1px #c0c1c7",
 | 
			
		||||
      color: "#c0c1c7",
 | 
			
		||||
      ":hover": {
 | 
			
		||||
        backgroundColor: "rgba(255, 255, 255, 0.1)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  dark: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: vars.color.darkBackground,
 | 
			
		||||
      color: "#c0c1c7",
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										27
									
								
								src/renderer/components/button/button.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/renderer/components/button/button.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import cn from "classnames";
 | 
			
		||||
import * as styles from "./button.css";
 | 
			
		||||
 | 
			
		||||
export interface ButtonProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    HTMLButtonElement
 | 
			
		||||
  > {
 | 
			
		||||
  theme?: keyof typeof styles.button;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Button({
 | 
			
		||||
  children,
 | 
			
		||||
  theme = "primary",
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: ButtonProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      {...props}
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={cn(styles.button[theme], className)}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/renderer/components/checkbox-field/checkbox-field.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/renderer/components/checkbox-field/checkbox-field.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
export const checkboxField = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "row",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkbox = style({
 | 
			
		||||
  width: "20px",
 | 
			
		||||
  height: "20px",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  backgroundColor: vars.color.darkBackground,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    borderColor: "rgba(255, 255, 255, 0.5)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkboxInput = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  opacity: "0",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkboxLabel = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										32
									
								
								src/renderer/components/checkbox-field/checkbox-field.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/renderer/components/checkbox-field/checkbox-field.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import { useId } from "react";
 | 
			
		||||
import * as styles from "./checkbox-field.css";
 | 
			
		||||
import { CheckIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
export interface CheckboxFieldProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.InputHTMLAttributes<HTMLInputElement>,
 | 
			
		||||
    HTMLInputElement
 | 
			
		||||
  > {
 | 
			
		||||
  label: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
 | 
			
		||||
  const id = useId();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.checkboxField}>
 | 
			
		||||
      <div className={styles.checkbox}>
 | 
			
		||||
        <input
 | 
			
		||||
          id={id}
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          className={styles.checkboxInput}
 | 
			
		||||
          {...props}
 | 
			
		||||
        />
 | 
			
		||||
        {props.checked && <CheckIcon />}
 | 
			
		||||
      </div>
 | 
			
		||||
      <label htmlFor={id} className={styles.checkboxLabel}>
 | 
			
		||||
        {label}
 | 
			
		||||
      </label>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										126
									
								
								src/renderer/components/game-card/game-card.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/renderer/components/game-card/game-card.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,126 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const card = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    height: "180px",
 | 
			
		||||
    boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
    overflow: "hidden",
 | 
			
		||||
    borderRadius: "4px",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    cursor: "pointer",
 | 
			
		||||
    zIndex: "1",
 | 
			
		||||
    ":active": {
 | 
			
		||||
      opacity: vars.opacity.active,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    disabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        pointerEvents: "none",
 | 
			
		||||
        boxShadow: "none",
 | 
			
		||||
        opacity: vars.opacity.disabled,
 | 
			
		||||
        filter: "grayscale(50%)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = style({
 | 
			
		||||
  background: "linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%)",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  justifyContent: "flex-end",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const cover = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  objectFit: "cover",
 | 
			
		||||
  objectPosition: "center",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  zIndex: "-1",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
      transform: "scale(1.05)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "flex-start",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  transform: "translateY(24px)",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
      transform: "translateY(0px)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const title = style({
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
  fontWeight: "bold",
 | 
			
		||||
  textAlign: "left",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadOptions = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  flexWrap: "wrap",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadOption = style({
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  fontSize: "10px",
 | 
			
		||||
  padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
 | 
			
		||||
  border: "solid 1px #c0c1c7",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const specifics = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const specificsItem = style({
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  fontSize: "12px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const titleContainer = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const shopIcon = style({
 | 
			
		||||
  width: "20px",
 | 
			
		||||
  height: "20px",
 | 
			
		||||
  minWidth: "20px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const noDownloadsLabel = style({
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
  fontWeight: "bold",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										87
									
								
								src/renderer/components/game-card/game-card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/renderer/components/game-card/game-card.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
 | 
			
		||||
import type { CatalogueEntry } from "@types";
 | 
			
		||||
 | 
			
		||||
import SteamLogo from "@renderer/assets/steam-logo.svg";
 | 
			
		||||
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg";
 | 
			
		||||
 | 
			
		||||
import { AsyncImage } from "../async-image/async-image";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./game-card.css";
 | 
			
		||||
import { useAppSelector } from "@renderer/hooks";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
export interface GameCardProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    HTMLButtonElement
 | 
			
		||||
  > {
 | 
			
		||||
  game: CatalogueEntry;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const shopIcon = {
 | 
			
		||||
  epic: <EpicGamesLogo className={styles.shopIcon} />,
 | 
			
		||||
  steam: <SteamLogo className={styles.shopIcon} />,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function GameCard({ game, disabled, ...props }: GameCardProps) {
 | 
			
		||||
  const { t } = useTranslation("game_card");
 | 
			
		||||
 | 
			
		||||
  const repackersFriendlyNames = useAppSelector(
 | 
			
		||||
    (state) => state.repackersFriendlyNames.value
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const uniqueRepackers = Array.from(
 | 
			
		||||
    new Set(game.repacks.map(({ repacker }) => repacker))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      {...props}
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={styles.card({ disabled })}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <AsyncImage
 | 
			
		||||
          src={game.cover}
 | 
			
		||||
          alt={game.title}
 | 
			
		||||
          className={styles.cover}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div className={styles.content}>
 | 
			
		||||
          <div className={styles.titleContainer}>
 | 
			
		||||
            {shopIcon[game.shop]}
 | 
			
		||||
            <p className={styles.title}>{game.title}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {uniqueRepackers.length > 0 ? (
 | 
			
		||||
            <ul className={styles.downloadOptions}>
 | 
			
		||||
              {uniqueRepackers.map((repacker) => (
 | 
			
		||||
                <li key={repacker} className={styles.downloadOption}>
 | 
			
		||||
                  <span>{repackersFriendlyNames[repacker]}</span>
 | 
			
		||||
                </li>
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <div className={styles.specifics}>
 | 
			
		||||
            <div className={styles.specificsItem}>
 | 
			
		||||
              <DownloadIcon />
 | 
			
		||||
              <span>{game.repacks.length}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {game.repacks.length > 0 && (
 | 
			
		||||
              <div className={styles.specificsItem}>
 | 
			
		||||
                <FileDirectoryIcon />
 | 
			
		||||
                <span>{game.repacks.at(0)?.fileSize}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/renderer/components/header/header.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/renderer/components/header/header.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,91 @@
 | 
			
		|||
import type { ComplexStyleRule } from "@vanilla-extract/css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const header = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    justifyContent: "space-between",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
    WebkitAppRegion: "drag",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
 | 
			
		||||
    color: "#c0c1c7",
 | 
			
		||||
    borderBottom: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    backgroundColor: vars.color.darkBackground,
 | 
			
		||||
  } as ComplexStyleRule,
 | 
			
		||||
  variants: {
 | 
			
		||||
    draggingDisabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        WebkitAppRegion: "no-drag",
 | 
			
		||||
      } as ComplexStyleRule,
 | 
			
		||||
    },
 | 
			
		||||
    isWindows: {
 | 
			
		||||
      true: {
 | 
			
		||||
        WebkitAppRegion: "no-drag",
 | 
			
		||||
      } as ComplexStyleRule,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const search = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    backgroundColor: vars.color.background,
 | 
			
		||||
    display: "inline-flex",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
    width: "200px",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    borderRadius: "8px",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    height: "40px",
 | 
			
		||||
    WebkitAppRegion: "no-drag",
 | 
			
		||||
  } as ComplexStyleRule,
 | 
			
		||||
  variants: {
 | 
			
		||||
    focused: {
 | 
			
		||||
      true: {
 | 
			
		||||
        width: "250px",
 | 
			
		||||
        borderColor: "#DADBE1",
 | 
			
		||||
      },
 | 
			
		||||
      false: {
 | 
			
		||||
        ":hover": {
 | 
			
		||||
          borderColor: "rgba(255, 255, 255, 0.5)",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const searchInput = style({
 | 
			
		||||
  backgroundColor: "transparent",
 | 
			
		||||
  border: "none",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  outline: "none",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  cursor: "default",
 | 
			
		||||
  fontFamily: "inherit",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  textOverflow: "ellipsis",
 | 
			
		||||
  ":focus": {
 | 
			
		||||
    cursor: "text",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionButton = style({
 | 
			
		||||
  color: "inherit",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  padding: `${SPACING_UNIT}px`,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    color: "#DADBE1",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const leftContent = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  height: "100%",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										103
									
								
								src/renderer/components/header/header.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/renderer/components/header/header.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,103 @@
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import { useLocation } from "react-router-dom";
 | 
			
		||||
import { SearchIcon, XIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./header.css";
 | 
			
		||||
import { clearSearch } from "@renderer/features";
 | 
			
		||||
 | 
			
		||||
export interface HeaderProps {
 | 
			
		||||
  onSearch: (query: string) => void;
 | 
			
		||||
  onClear: () => void;
 | 
			
		||||
  search?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const pathTitle: Record<string, string> = {
 | 
			
		||||
  "/": "catalogue",
 | 
			
		||||
  "/downloads": "downloads",
 | 
			
		||||
  "/search": "search_results",
 | 
			
		||||
  "/settings": "settings",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
 | 
			
		||||
  const inputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
  const { headerTitle, draggingDisabled } = useAppSelector(
 | 
			
		||||
    (state) => state.window
 | 
			
		||||
  );
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
 | 
			
		||||
  const [isFocused, setIsFocused] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("header");
 | 
			
		||||
 | 
			
		||||
  const title = useMemo(() => {
 | 
			
		||||
    if (location.pathname.startsWith("/game")) return headerTitle;
 | 
			
		||||
 | 
			
		||||
    return t(pathTitle[location.pathname]);
 | 
			
		||||
  }, [location.pathname, headerTitle, t]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (search && location.pathname !== "/search") {
 | 
			
		||||
      dispatch(clearSearch());
 | 
			
		||||
    }
 | 
			
		||||
  }, [location.pathname, search, dispatch]);
 | 
			
		||||
 | 
			
		||||
  const focusInput = () => {
 | 
			
		||||
    setIsFocused(true);
 | 
			
		||||
    inputRef.current?.focus();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleBlur = () => {
 | 
			
		||||
    setIsFocused(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <header
 | 
			
		||||
      className={styles.header({
 | 
			
		||||
        draggingDisabled,
 | 
			
		||||
        isWindows: window.electron.platform === "win32",
 | 
			
		||||
      })}
 | 
			
		||||
    >
 | 
			
		||||
      <h3>{title}</h3>
 | 
			
		||||
 | 
			
		||||
      <section className={styles.leftContent}>
 | 
			
		||||
        <div className={styles.search({ focused: isFocused })}>
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            className={styles.actionButton}
 | 
			
		||||
            onClick={focusInput}
 | 
			
		||||
          >
 | 
			
		||||
            <SearchIcon />
 | 
			
		||||
          </button>
 | 
			
		||||
 | 
			
		||||
          <input
 | 
			
		||||
            ref={inputRef}
 | 
			
		||||
            type="text"
 | 
			
		||||
            name="search"
 | 
			
		||||
            placeholder={t("search")}
 | 
			
		||||
            value={search}
 | 
			
		||||
            className={styles.searchInput}
 | 
			
		||||
            onChange={(event) => onSearch(event.target.value)}
 | 
			
		||||
            onFocus={() => setIsFocused(true)}
 | 
			
		||||
            onBlur={handleBlur}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {search && (
 | 
			
		||||
            <button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={onClear}
 | 
			
		||||
              className={styles.actionButton}
 | 
			
		||||
            >
 | 
			
		||||
              <XIcon />
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </section>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										58
									
								
								src/renderer/components/hero/hero.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/renderer/components/hero/hero.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
 | 
			
		||||
export const hero = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "280px",
 | 
			
		||||
  minHeight: "280px",
 | 
			
		||||
  maxHeight: "280px",
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  zIndex: "1",
 | 
			
		||||
  "@media": {
 | 
			
		||||
    "(min-width: 1250px)": {
 | 
			
		||||
      backgroundPosition: "center",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const heroMedia = style({
 | 
			
		||||
  objectFit: "cover",
 | 
			
		||||
  objectPosition: "center",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  zIndex: "-1",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  background: "linear-gradient(0deg, rgba(0, 0, 0, 0.6) 25%, transparent 100%)",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const description = style({
 | 
			
		||||
  maxWidth: "700px",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  textAlign: "left",
 | 
			
		||||
  fontFamily: "'Fira Sans', sans-serif",
 | 
			
		||||
  lineHeight: "20px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  justifyContent: "flex-end",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										59
									
								
								src/renderer/components/hero/hero.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/renderer/components/hero/hero.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { AsyncImage } from "@renderer/components";
 | 
			
		||||
import * as styles from "./hero.css";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { ShopDetails } from "@types";
 | 
			
		||||
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
const FEATURED_GAME_ID = "1144200";
 | 
			
		||||
 | 
			
		||||
export function Hero() {
 | 
			
		||||
  const [featuredGameDetails, setFeaturedGameDetails] =
 | 
			
		||||
    useState<ShopDetails | null>(null);
 | 
			
		||||
 | 
			
		||||
  const { i18n } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.electron
 | 
			
		||||
      .getGameShopDetails(
 | 
			
		||||
        FEATURED_GAME_ID,
 | 
			
		||||
        "steam",
 | 
			
		||||
        getSteamLanguage(i18n.language)
 | 
			
		||||
      )
 | 
			
		||||
      .then((result) => {
 | 
			
		||||
        setFeaturedGameDetails(result);
 | 
			
		||||
      });
 | 
			
		||||
  }, [i18n.language]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      type="button"
 | 
			
		||||
      onClick={() => navigate("/game/steam/1144200")}
 | 
			
		||||
      className={styles.hero}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <AsyncImage
 | 
			
		||||
          src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
 | 
			
		||||
          alt={featuredGameDetails?.name}
 | 
			
		||||
          className={styles.heroMedia}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div className={styles.content}>
 | 
			
		||||
          <AsyncImage
 | 
			
		||||
            src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
 | 
			
		||||
            width="250px"
 | 
			
		||||
            alt={featuredGameDetails?.name}
 | 
			
		||||
            style={{ marginBottom: 16 }}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <p className={styles.description}>
 | 
			
		||||
            {featuredGameDetails?.short_description}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/renderer/components/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/renderer/components/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
export * from "./bottom-panel/bottom-panel";
 | 
			
		||||
export * from "./button/button";
 | 
			
		||||
export * from "./game-card/game-card";
 | 
			
		||||
export * from "./header/header";
 | 
			
		||||
export * from "./hero/hero";
 | 
			
		||||
export * from "./modal/modal";
 | 
			
		||||
export * from "./sidebar/sidebar";
 | 
			
		||||
export * from "./async-image/async-image";
 | 
			
		||||
export * from "./text-field/text-field";
 | 
			
		||||
export * from "./checkbox-field/checkbox-field";
 | 
			
		||||
							
								
								
									
										108
									
								
								src/renderer/components/modal/modal.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/renderer/components/modal/modal.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
import { keyframes, style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const backdropFadeIn = keyframes({
 | 
			
		||||
  "0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    backdropFilter: "blur(2px)",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0.7)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdropFadeOut = keyframes({
 | 
			
		||||
  "0%": { backdropFilter: "blur(2px)", backgroundColor: "rgba(0, 0, 0, 0.7)" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    backdropFilter: "blur(0px)",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalSlideIn = keyframes({
 | 
			
		||||
  "0%": { opacity: 0 },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    opacity: 1,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalSlideOut = keyframes({
 | 
			
		||||
  "0%": { opacity: 1 },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    opacity: 0,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    animationName: backdropFadeIn,
 | 
			
		||||
    animationDuration: "0.4s",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0.7)",
 | 
			
		||||
    position: "absolute",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    height: "100%",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    justifyContent: "center",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    zIndex: 1,
 | 
			
		||||
    top: 0,
 | 
			
		||||
    padding: `${SPACING_UNIT * 3}px`,
 | 
			
		||||
    backdropFilter: "blur(2px)",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    closing: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animationName: backdropFadeOut,
 | 
			
		||||
        backdropFilter: "blur(0px)",
 | 
			
		||||
        backgroundColor: "rgba(0, 0, 0, 0)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modal = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    animationName: modalSlideIn,
 | 
			
		||||
    animationDuration: "0.3s",
 | 
			
		||||
    backgroundColor: vars.color.background,
 | 
			
		||||
    borderRadius: "5px",
 | 
			
		||||
    maxWidth: "600px",
 | 
			
		||||
    color: vars.color.bodyText,
 | 
			
		||||
    maxHeight: "100%",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    overflow: "hidden",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    flexDirection: "column",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    closing: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animationName: modalSlideOut,
 | 
			
		||||
        opacity: 0,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalContent = style({
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  overflow: "auto",
 | 
			
		||||
  padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalHeader = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  borderBottom: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  justifyContent: "space-between",
 | 
			
		||||
  alignItems: "flex-start",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const closeModalButton = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const closeModalButtonIcon = style({
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										69
									
								
								src/renderer/components/modal/modal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/renderer/components/modal/modal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,69 @@
 | 
			
		|||
import { useEffect, useState } from "react";
 | 
			
		||||
import { createPortal } from "react-dom";
 | 
			
		||||
import { XIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./modal.css";
 | 
			
		||||
import { useAppDispatch } from "@renderer/hooks";
 | 
			
		||||
import { toggleDragging } from "@renderer/features";
 | 
			
		||||
 | 
			
		||||
export interface ModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Modal({
 | 
			
		||||
  visible,
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  onClose,
 | 
			
		||||
  children,
 | 
			
		||||
}: ModalProps) {
 | 
			
		||||
  const [isClosing, setIsClosing] = useState(false);
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  const handleCloseClick = () => {
 | 
			
		||||
    setIsClosing(true);
 | 
			
		||||
    const zero = performance.now();
 | 
			
		||||
 | 
			
		||||
    requestAnimationFrame(function animateClosing(time) {
 | 
			
		||||
      if (time - zero <= 400) {
 | 
			
		||||
        requestAnimationFrame(animateClosing);
 | 
			
		||||
      } else {
 | 
			
		||||
        onClose();
 | 
			
		||||
        setIsClosing(false);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    dispatch(toggleDragging(visible));
 | 
			
		||||
  }, [dispatch, visible]);
 | 
			
		||||
 | 
			
		||||
  if (!visible) return null;
 | 
			
		||||
 | 
			
		||||
  return createPortal(
 | 
			
		||||
    <div className={styles.backdrop({ closing: isClosing })}>
 | 
			
		||||
      <div className={styles.modal({ closing: isClosing })}>
 | 
			
		||||
        <div className={styles.modalHeader}>
 | 
			
		||||
          <div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
 | 
			
		||||
            <h3>{title}</h3>
 | 
			
		||||
            <p style={{ fontSize: 14 }}>{description}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={handleCloseClick}
 | 
			
		||||
            className={styles.closeModalButton}
 | 
			
		||||
          >
 | 
			
		||||
            <XIcon className={styles.closeModalButtonIcon} size={24} />
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={styles.modalContent}>{children}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>,
 | 
			
		||||
    document.body
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
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