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
	
	 GitHub Actions
						GitHub Actions