From 86de5aa89e95cecfea0026c773c5c318351616a5 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 13 May 2025 22:57:33 +0100 Subject: [PATCH] feat: adding possibility to create steam shortcut --- package.json | 4 + src/locales/en/translation.json | 2 + src/locales/es/translation.json | 2 + src/locales/pt-BR/translation.json | 2 + src/main/events/index.ts | 1 + .../events/library/create-steam-shortcut.ts | 131 ++++++++++++++++++ src/main/main.ts | 4 +- src/main/services/python-rpc.ts | 20 --- src/main/services/steam.ts | 124 ++++++++++++++++- src/preload/index.ts | 2 + src/renderer/src/declaration.d.ts | 1 + .../modals/game-options-modal.tsx | 20 +++ src/types/steam.types.ts | 19 +++ yarn.lock | 34 ++++- 14 files changed, 342 insertions(+), 24 deletions(-) create mode 100644 src/main/events/library/create-steam-shortcut.ts diff --git a/package.json b/package.json index 18d28cd0..ff23ffba 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "classnames": "^2.5.1", "color": "^4.2.3", "color.js": "^1.2.0", + "crc": "^4.3.2", "create-desktop-shortcuts": "^1.11.1", "date-fns": "^3.6.0", "dexie": "^4.0.10", @@ -70,10 +71,12 @@ "react-router-dom": "^6.22.3", "react-tooltip": "^5.28.0", "sound-play": "^1.1.0", + "steam-shortcut-editor": "^3.1.3", "sudo-prompt": "^9.2.1", "tar": "^7.4.3", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", + "winreg": "^1.2.5", "ws": "^8.18.1", "yaml": "^2.6.1", "yup": "^1.5.0", @@ -99,6 +102,7 @@ "@types/react-dom": "^18.2.18", "@types/sound-play": "^1.1.3", "@types/user-agents": "^1.0.4", + "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", "electron": "^31.7.7", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index d226e8e1..675f691f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -130,7 +130,9 @@ "download_in_progress": "Download in progress", "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", + "create_steam_shortcut": "Create Steam shortcut", "create_shortcut_success": "Shortcut created successfully", + "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", "create_shortcut_error": "Error creating shortcut", "nsfw_content_title": "This game contains innapropriate content", "nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 82dad2ba..716f7b33 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -130,8 +130,10 @@ "danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)", "download_in_progress": "Descarga en progreso", "download_paused": "Descarga pausada", + "create_steam_shortcut": "Crear atajo de Steam", "last_downloaded_option": "Última opción descargada", "create_shortcut_success": "Atajo creado con éxito", + "you_might_need_to_restart_steam": "Es posible que necesites reiniciar Steam para ver los cambios", "create_shortcut_error": "Error al crear un atajo", "nsfw_content_title": "Este juego contiene contenido inapropiado.", "nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 56dc8d44..5509e07b 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -118,7 +118,9 @@ "download_in_progress": "Download em andamento", "download_paused": "Download pausado", "last_downloaded_option": "Última opção baixada", + "create_steam_shortcut": "Criar atalho na Steam", "create_shortcut_success": "Atalho criado com sucesso", + "you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações", "create_shortcut_error": "Erro ao criar atalho", "nsfw_content_title": "Este jogo contém conteúdo inapropriado", "nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index acc589f9..ad72163e 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -35,6 +35,7 @@ import "./library/select-game-wine-prefix"; import "./library/reset-game-achievements"; import "./library/toggle-automatic-cloud-sync"; import "./library/get-default-wine-prefix-selection-path"; +import "./library/create-steam-shortcut"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; diff --git a/src/main/events/library/create-steam-shortcut.ts b/src/main/events/library/create-steam-shortcut.ts new file mode 100644 index 00000000..f0a27670 --- /dev/null +++ b/src/main/events/library/create-steam-shortcut.ts @@ -0,0 +1,131 @@ +import { registerEvent } from "../register-event"; +import type { GameShop, GameStats } from "@types"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { + composeSteamShortcut, + getSteamLocation, + getSteamShortcuts, + getSteamUserId, + HydraApi, + logger, + SystemPath, + writeSteamShortcuts, +} from "@main/services"; +import fs from "node:fs"; +import axios from "axios"; +import path from "node:path"; + +const downloadAsset = async (downloadPath: string, url?: string | null) => { + try { + if (fs.existsSync(downloadPath)) { + return downloadPath; + } + + if (!url) { + return null; + } + + fs.mkdirSync(path.dirname(downloadPath), { recursive: true }); + + const response = await axios.get(url, { responseType: "arraybuffer" }); + fs.writeFileSync(downloadPath, response.data); + + return downloadPath; + } catch (error) { + logger.error("Failed to download asset", error); + return null; + } +}; + +const createSteamShortcut = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string +) => { + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); + + if (game) { + if (!game.executablePath) { + throw new Error("No executable path found for game"); + } + + const { assets } = await HydraApi.get( + `/games/stats?objectId=${objectId}&shop=${shop}` + ); + + const steamUserId = getSteamUserId(); + + if (!steamUserId) { + logger.error("No Steam user ID found"); + return; + } + + logger.info("Got Steam user id", steamUserId); + + const steamShortcuts = await getSteamShortcuts(steamUserId); + + if ( + steamShortcuts.some( + (shortcut) => + shortcut.Exe === game.executablePath && + shortcut.appname === game.title + ) + ) { + return; + } + + const icon = await downloadAsset( + path.join( + SystemPath.getPath("userData"), + "Icons", + `${game.shop}-${game.objectId}.ico` + ), + assets?.iconUrl + ); + + const newShortcut = composeSteamShortcut( + game.title, + game.executablePath, + icon + ); + + const gridPath = path.join( + await getSteamLocation(), + "userdata", + steamUserId.toString(), + "config", + "grid" + ); + + fs.mkdirSync(gridPath, { recursive: true }); + + await Promise.allSettled([ + downloadAsset( + path.join(gridPath, `${newShortcut.appid}_hero.jpg`), + assets?.libraryHeroImageUrl + ), + downloadAsset( + path.join(gridPath, `${newShortcut.appid}_logo.png`), + assets?.logoImageUrl + ), + downloadAsset( + path.join(gridPath, `${newShortcut.appid}p.jpg`), + assets?.coverImageUrl + ), + downloadAsset( + path.join(gridPath, `${newShortcut.appid}.jpg`), + assets?.libraryImageUrl + ), + ]); + + steamShortcuts.push(newShortcut); + + logger.info(newShortcut); + logger.info("Writing Steam shortcuts", steamShortcuts); + + return writeSteamShortcuts(steamUserId, steamShortcuts); + } +}; + +registerEvent("createSteamShortcut", createSteamShortcut); diff --git a/src/main/main.ts b/src/main/main.ts index b0e142fc..ec524747 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -29,7 +29,9 @@ export const loadState = async () => { await import("./events"); - Aria2.spawn(); + if (process.platform !== "darwin") { + Aria2.spawn(); + } if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 1577b1e6..da4f1e71 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -22,12 +22,6 @@ const binaryNameByPlatform: Partial> = { win32: "hydra-python-rpc.exe", }; -const rustBinaryNameByPlatform: Partial> = { - darwin: "hydra-httpdl", - linux: "hydra-httpdl", - win32: "hydra-httpdl.exe", -}; - export class PythonRPC { public static readonly BITTORRENT_PORT = "5881"; public static readonly RPC_PORT = "8084"; @@ -72,20 +66,6 @@ export class PythonRPC { rpcPassword, initialDownload ? JSON.stringify(initialDownload) : "", initialSeeding ? JSON.stringify(initialSeeding) : "", - app.isPackaged - ? path.join( - process.resourcesPath, - rustBinaryNameByPlatform[process.platform]! - ) - : path.join( - __dirname, - "..", - "..", - "rust_rpc", - "target", - "debug", - rustBinaryNameByPlatform[process.platform]! - ), ]; if (app.isPackaged) { diff --git a/src/main/services/steam.ts b/src/main/services/steam.ts index 18cd8d11..fff87422 100644 --- a/src/main/services/steam.ts +++ b/src/main/services/steam.ts @@ -1,8 +1,14 @@ import axios from "axios"; +import path from "node:path"; +import fs from "node:fs"; +import { crc32 } from "crc"; +import WinReg from "winreg"; +import { parseBuffer, writeBuffer } from "steam-shortcut-editor"; -import type { SteamAppDetails } from "@types"; +import type { SteamAppDetails, SteamShortcut } from "@types"; import { logger } from "./logger"; +import { SystemPath } from "./system-path"; export interface SteamAppDetailsResponse { [key: string]: { @@ -11,6 +17,36 @@ export interface SteamAppDetailsResponse { }; } +export const getSteamLocation = async (): Promise => { + if (process.platform === "linux") { + return path.join(SystemPath.getPath("home"), ".local", "share", "Steam"); + } + + if (process.platform === "darwin") { + return path.join( + SystemPath.getPath("home"), + "Library", + "Application Support", + "Steam" + ); + } + + const regKey = new WinReg({ + hive: WinReg.HKCU, + key: "\\Software\\Valve\\Steam", + }); + + return new Promise((resolve, reject) => { + regKey.get("SteamPath", (err, value) => { + if (err) { + reject(err); + } + + resolve(value.value); + }); + }); +}; + export const getSteamAppDetails = async ( objectId: string, language: string @@ -40,3 +76,89 @@ export const getSteamAppDetails = async ( return null; }); }; + +export const getSteamUserId = () => { + const userDataPath = path.join( + SystemPath.getPath("appData"), + "Steam", + "userdata" + ); + + const userIds = fs.readdirSync(userDataPath, { withFileTypes: true }); + + const [steamUserId] = userIds.filter((dir) => dir.isDirectory()); + if (!steamUserId) { + return null; + } + + return Number(steamUserId.name); +}; + +export const getSteamShortcuts = async (steamUserId: number) => { + const shortcuts = parseBuffer( + fs.readFileSync( + path.join( + await getSteamLocation(), + "userdata", + steamUserId.toString(), + "config", + "shortcuts.vdf" + ) + ) + ); + + return shortcuts.shortcuts as SteamShortcut[]; +}; + +export const generateSteamShortcutAppId = ( + exePath: string, + gameName: string +) => { + const input = exePath + gameName; + const crcValue = crc32(input) >>> 0; + const steamAppId = (crcValue | 0x80000000) >>> 0; + return steamAppId; +}; + +export const composeSteamShortcut = ( + title: string, + executablePath: string, + iconPath: string | null +): SteamShortcut => { + return { + appid: generateSteamShortcutAppId(executablePath, title), + appname: title, + Exe: executablePath, + StartDir: path.dirname(executablePath), + icon: iconPath ?? "", + ShortcutPath: "", + LaunchOptions: "", + IsHidden: false, + AllowDesktopConfig: true, + AllowOverlay: true, + OpenVR: false, + Devkit: false, + DevkitGameID: "", + DevkitOverrideAppID: false, + LastPlayTime: false, + FlatpakAppID: "", + }; +}; + +export const writeSteamShortcuts = async ( + steamUserId: number, + shortcuts: SteamShortcut[] +) => { + const buffer = writeBuffer({ shortcuts }); + + fs.writeFileSync( + path.join( + await getSteamLocation(), + "userdata", + steamUserId.toString(), + "config", + "shortcuts.vdf" + ), + buffer + ); +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index 981901d3..6695fa2b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -191,6 +191,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("extractGameDownload", shop, objectId), getDefaultWinePrefixSelectionPath: () => ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"), + createSteamShortcut: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("createSteamShortcut", shop, objectId), onGamesRunning: ( cb: ( gamesRunning: Pick[] diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0dee5767..7eaa4ee0 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -180,6 +180,7 @@ declare global { cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; getDefaultWinePrefixSelectionPath: () => Promise; + createSteamShortcut: (shop: GameShop, objectId: string) => Promise; /* Download sources */ putDownloadSource: ( diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 991f6a9d..c178d0c1 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -8,8 +8,10 @@ import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; import { ResetAchievementsModal } from "./reset-achievements-modal"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; +import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { debounce } from "lodash-es"; import "./game-options-modal.scss"; +import { logger } from "@renderer/logger"; export interface GameOptionsModalProps { visible: boolean; @@ -107,6 +109,20 @@ export function GameOptionsModal({ } }; + const handleCreateSteamShortcut = async () => { + try { + await window.electron.createSteamShortcut(game.shop, game.objectId); + + showSuccessToast( + t("create_shortcut_success"), + t("you_might_need_to_restart_steam") + ); + } catch (error: unknown) { + logger.error("Failed to create Steam shortcut", error); + showErrorToast(t("create_shortcut_error")); + } + }; + const handleCreateShortcut = async (location: ShortcutLocation) => { window.electron .createGameShortcut(game.shop, game.objectId, location) @@ -298,6 +314,10 @@ export function GameOptionsModal({ > {t("create_shortcut")} + {shouldShowCreateStartMenuShortcut && (