Repository: hydralauncher/hydra
Pull request #1689: feat: adding possibility to create steam shortcut
Author: login: chubbygrannychaser
Created: 2025-05-13T21:58:50Z | Merged: 2025-05-14T20:57:54Z

<!-- Please be sure to add one of the labels in the right hand side Labels option before creating a PR: [feature], [fix], [documentation],[translation]. This will allow Actions to automatically categorize PRs when generating Releases. -->

**When submitting this pull request, I confirm the following (please check the boxes):**

- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute).
- [ ] I have checked that there are no duplicate pull requests related to this request.
- [ ] I have considered, and confirm that this submission is valuable to others.
- [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers.

**Fill in the PR content:**

-



Diff:
diff --git a/package.json b/package.json
index 18d28cd04..9bd5ba296 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": "https://github.com/hydralauncher/steam-shortcut-editor",
     "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 d226e8e14..2902218e0 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -130,9 +130,11 @@
     "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_title": "This game contains inappropriate content",
     "nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
     "allow_nsfw_content": "Continue",
     "refuse_nsfw_content": "Go back",
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index 82dad2baa..716f7b333 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 56dc8d440..5509e07b1 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/constants.ts b/src/main/constants.ts
index 0f892af7b..243891e76 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -40,4 +40,6 @@ export const backupsPath = path.join(SystemPath.getPath("userData"), "Backups");
 
 export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
 
+export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
+
 export const MAIN_LOOP_INTERVAL = 1500;
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index acc589f98..ad72163e1 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 000000000..faa692662
--- /dev/null
+++ b/src/main/events/library/create-steam-shortcut.ts
@@ -0,0 +1,181 @@
+import { registerEvent } from "../register-event";
+import type { GameShop, GameStats } from "@types";
+import { gamesSublevel, levelKeys } from "@main/level";
+import {
+  composeSteamShortcut,
+  getSteamLocation,
+  getSteamShortcuts,
+  getSteamUsersIds,
+  HydraApi,
+  logger,
+  SystemPath,
+  writeSteamShortcuts,
+} from "@main/services";
+import fs from "node:fs";
+import axios from "axios";
+import path from "node:path";
+import { ASSETS_PATH } from "@main/constants";
+
+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 downloadAssetsFromSteam = async (
+  shop: GameShop,
+  objectId: string,
+  assets: GameStats["assets"]
+) => {
+  const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
+
+  return await Promise.all([
+    downloadAsset(path.join(gameAssetsPath, "icon.ico"), assets?.iconUrl),
+    downloadAsset(
+      path.join(gameAssetsPath, "hero.jpg"),
+      assets?.libraryHeroImageUrl
+    ),
+    downloadAsset(path.join(gameAssetsPath, "logo.png"), assets?.logoImageUrl),
+    downloadAsset(
+      path.join(gameAssetsPath, "cover.jpg"),
+      assets?.coverImageUrl
+    ),
+    downloadAsset(
+      path.join(gameAssetsPath, "library.jpg"),
+      assets?.libraryImageUrl
+    ),
+  ]);
+};
+
+const copyAssetIfExists = async (
+  sourcePath: string | null,
+  destinationPath: string
+) => {
+  if (sourcePath && fs.existsSync(sourcePath)) {
+    logger.info("Copying Steam asset", sourcePath, destinationPath);
+    await fs.promises.cp(sourcePath, destinationPath);
+  }
+};
+
+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<GameStats>(
+      `/games/stats?objectId=${objectId}&shop=${shop}`
+    );
+
+    const steamUserIds = await getSteamUsersIds();
+
+    if (!steamUserIds.length) {
+      logger.error("No Steam user ID found");
+      return;
+    }
+
+    const [iconImage, heroImage, logoImage, coverImage, libraryImage] =
+      await downloadAssetsFromSteam(game.shop, game.objectId, assets);
+
+    const newShortcut = composeSteamShortcut(
+      game.title,
+      game.executablePath,
+      iconImage
+    );
+
+    for (const steamUserId of steamUserIds) {
+      logger.info("Adding shortcut for Steam user", steamUserId);
+
+      const steamShortcuts = await getSteamShortcuts(steamUserId);
+
+      if (steamShortcuts.some((shortcut) => shortcut.appname === game.title)) {
+        continue;
+      }
+
+      const gridPath = path.join(
+        await getSteamLocation(),
+        "userdata",
+        steamUserId.toString(),
+        "config",
+        "grid"
+      );
+
+      await fs.promises.mkdir(gridPath, { recursive: true });
+
+      await Promise.all([
+        copyAssetIfExists(
+          heroImage,
+          path.join(gridPath, `${newShortcut.appid}_hero.jpg`)
+        ),
+        copyAssetIfExists(
+          logoImage,
+          path.join(gridPath, `${newShortcut.appid}_logo.png`)
+        ),
+        copyAssetIfExists(
+          coverImage,
+          path.join(gridPath, `${newShortcut.appid}p.jpg`)
+        ),
+        copyAssetIfExists(
+          libraryImage,
+          path.join(gridPath, `${newShortcut.appid}.jpg`)
+        ),
+      ]);
+
+      steamShortcuts.push(newShortcut);
+
+      logger.info(newShortcut);
+      logger.info("Writing Steam shortcuts", steamShortcuts);
+
+      await writeSteamShortcuts(steamUserId, steamShortcuts);
+    }
+
+    if (process.platform === "linux" && !game.winePrefixPath) {
+      const steamWinePrefixes = path.join(
+        SystemPath.getPath("home"),
+        ".local",
+        "share",
+        "Steam",
+        "steamapps",
+        "compatdata"
+      );
+
+      const winePrefixPath = path.join(
+        steamWinePrefixes,
+        newShortcut.appid.toString(),
+        "pfx"
+      );
+
+      await fs.promises.mkdir(winePrefixPath, { recursive: true });
+
+      await gamesSublevel.put(gameKey, {
+        ...game,
+        winePrefixPath,
+      });
+    }
+  }
+};
+
+registerEvent("createSteamShortcut", createSteamShortcut);
diff --git a/src/main/events/library/get-default-wine-prefix-selection-path.ts b/src/main/events/library/get-default-wine-prefix-selection-path.ts
index 94f57d389..67f5a82e9 100644
--- a/src/main/events/library/get-default-wine-prefix-selection-path.ts
+++ b/src/main/events/library/get-default-wine-prefix-selection-path.ts
@@ -16,11 +16,7 @@ const getDefaultWinePrefixSelectionPath = async (
       "compatdata"
     );
 
-    if (fs.existsSync(steamWinePrefixes)) {
-      return fs.promises.realpath(steamWinePrefixes);
-    }
-
-    return null;
+    return await fs.promises.realpath(steamWinePrefixes);
   } catch (err) {
     logger.error("Failed to get default wine prefix selection path", err);
 
diff --git a/src/main/events/library/verify-executable-path.ts b/src/main/events/library/verify-executable-path.ts
index a48a0d380..dc961116b 100644
--- a/src/main/events/library/verify-executable-path.ts
+++ b/src/main/events/library/verify-executable-path.ts
@@ -7,11 +7,11 @@ const verifyExecutablePathInUse = async (
 ) => {
   for await (const game of gamesSublevel.values()) {
     if (game.executablePath === executablePath) {
-      return true;
+      return game;
     }
   }
 
-  return false;
+  return null;
 };
 
 registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse);
diff --git a/src/main/main.ts b/src/main/main.ts
index 313087cef..a080eec26 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -27,7 +27,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 1577b1e64..da4f1e71f 100644
--- a/src/main/services/python-rpc.ts
+++ b/src/main/services/python-rpc.ts
@@ -22,12 +22,6 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
   win32: "hydra-python-rpc.exe",
 };
 
-const rustBinaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
-  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 18cd8d11a..70653fd06 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 () => {
+  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<string>((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,86 @@ export const getSteamAppDetails = async (
       return null;
     });
 };
+
+export const getSteamUsersIds = async () => {
+  const userDataPath = await getSteamLocation();
+
+  const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), {
+    withFileTypes: true,
+  });
+
+  return userIds
+    .filter((dir) => dir.isDirectory())
+    .map((dir) => Number(dir.name));
+};
+
+export const getSteamShortcuts = async (steamUserId: number) => {
+  const shortcutsPath = path.join(
+    await getSteamLocation(),
+    "userdata",
+    steamUserId.toString(),
+    "config",
+    "shortcuts.vdf"
+  );
+
+  if (!fs.existsSync(shortcutsPath)) {
+    return [];
+  }
+
+  const shortcuts = parseBuffer(fs.readFileSync(shortcutsPath));
+
+  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: 0,
+    FlatpakAppID: "",
+  };
+};
+
+export const writeSteamShortcuts = async (
+  steamUserId: number,
+  shortcuts: SteamShortcut[]
+) => {
+  const buffer = writeBuffer({ shortcuts });
+
+  return fs.promises.writeFile(
+    path.join(
+      await getSteamLocation(),
+      "userdata",
+      steamUserId.toString(),
+      "config",
+      "shortcuts.vdf"
+    ),
+    buffer
+  );
+};
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 981901d38..6695fa2bc 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<GameRunning, "id" | "sessionDurationInMillis">[]
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 0dee5767c..7eaa4ee05 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<string | null>;
+    createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
 
     /* 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 991f6a9dd..4c415f7b4 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;
@@ -45,6 +47,7 @@ export function GameOptionsModal({
   const [automaticCloudSync, setAutomaticCloudSync] = useState(
     game.automaticCloudSync ?? false
   );
+  const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
 
   const {
     removeGameInstaller,
@@ -107,6 +110,25 @@ export function GameOptionsModal({
     }
   };
 
+  const handleCreateSteamShortcut = async () => {
+    try {
+      setCreatingSteamShortcut(true);
+      await window.electron.createSteamShortcut(game.shop, game.objectId);
+
+      showSuccessToast(
+        t("create_shortcut_success"),
+        t("you_might_need_to_restart_steam")
+      );
+
+      updateGame();
+    } catch (error: unknown) {
+      logger.error("Failed to create Steam shortcut", error);
+      showErrorToast(t("create_shortcut_error"));
+    } finally {
+      setCreatingSteamShortcut(false);
+    }
+  };
+
   const handleCreateShortcut = async (location: ShortcutLocation) => {
     window.electron
       .createGameShortcut(game.shop, game.objectId, location)
@@ -142,9 +164,12 @@ export function GameOptionsModal({
   };
 
   const handleChangeWinePrefixPath = async () => {
+    const defaultPath =
+      await window.electron.getDefaultWinePrefixSelectionPath();
+
     const { filePaths } = await window.electron.showOpenDialog({
       properties: ["openDirectory"],
-      defaultPath: await window.electron.getDefaultWinePrefixSelectionPath(),
+      defaultPath: defaultPath ?? game?.winePrefixPath ?? "",
     });
 
     if (filePaths && filePaths.length > 0) {
@@ -298,6 +323,14 @@ export function GameOptionsModal({
                   >
                     {t("create_shortcut")}
                   </Button>
+                  <Button
+                    onClick={handleCreateSteamShortcut}
+                    theme="outline"
+                    disabled={creatingSteamShortcut}
+                  >
+                    <SteamLogo />
+                    {t("create_steam_shortcut")}
+                  </Button>
                   {shouldShowCreateStartMenuShortcut && (
                     <Button
                       onClick={() => handleCreateShortcut("start_menu")}
diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts
index fb5aef6ed..2721bb478 100644
--- a/src/types/steam.types.ts
+++ b/src/types/steam.types.ts
@@ -53,3 +53,22 @@ export interface SteamAppDetails {
     ids: number[];
   };
 }
+
+export interface SteamShortcut {
+  appid: number;
+  appname: string;
+  Exe: string;
+  StartDir: string;
+  icon: string;
+  ShortcutPath: string;
+  LaunchOptions: string;
+  IsHidden: boolean;
+  AllowDesktopConfig: boolean;
+  AllowOverlay: boolean;
+  OpenVR: boolean;
+  Devkit: boolean;
+  DevkitGameID: string;
+  DevkitOverrideAppID: boolean;
+  LastPlayTime: number;
+  FlatpakAppID: string;
+}
diff --git a/yarn.lock b/yarn.lock
index 5535b8045..91084f52a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3357,6 +3357,11 @@
   resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087"
   integrity sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==
 
+"@types/winreg@^1.2.36":
+  version "1.2.36"
+  resolved "https://registry.yarnpkg.com/@types/winreg/-/winreg-1.2.36.tgz#f1d9a9918cae90a63c6106c98224aca6a36983fc"
+  integrity sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==
+
 "@types/ws@^8.18.1":
   version "8.18.1"
   resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
@@ -4009,7 +4014,7 @@ buffer-from@^1.0.0:
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-buffer@^5.1.0, buffer@^5.5.0:
+buffer@^5.1.0, buffer@^5.5.0, buffer@^5.6.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
   integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
@@ -4442,6 +4447,11 @@ crc@^3.8.0:
   dependencies:
     buffer "^5.1.0"
 
+crc@^4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/crc/-/crc-4.3.2.tgz#49b7821cbf2cf61dfd079ed93863bbebd5469b9a"
+  integrity sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==
+
 create-desktop-shortcuts@^1.11.1:
   version "1.11.1"
   resolved "https://registry.yarnpkg.com/create-desktop-shortcuts/-/create-desktop-shortcuts-1.11.1.tgz#59f9dced7931bda551c0717791a909419472c809"
@@ -6914,7 +6924,7 @@ lodash.upperfirst@^4.3.1:
   resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce"
   integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==
 
-lodash@^4.17.15:
+lodash@^4.17.15, lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -7366,6 +7376,13 @@ object-keys@^1.1.1:
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
+object-sizeof@^1.2.0:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/object-sizeof/-/object-sizeof-1.6.3.tgz#6edbbf26825b971fd7a32125a800ed2a9895af95"
+  integrity sha512-LGtilAKuDGKCcvu1Xg3UvAhAeJJlFmblo3faltmOQ80xrGwAHxnauIXucalKdTEksHp/Pq9tZGz1hfyEmjFJPQ==
+  dependencies:
+    buffer "^5.6.0"
+
 object.assign@^4.1.4, object.assign@^4.1.5:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0"
@@ -8548,6 +8565,13 @@ state-local@^1.0.6:
   resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
   integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
 
+"steam-shortcut-editor@https://github.com/hydralauncher/steam-shortcut-editor":
+  version "3.1.3"
+  resolved "https://github.com/hydralauncher/steam-shortcut-editor#7d9ba44eced1f8840cf6c7d2cd40dd4d3d5c660f"
+  dependencies:
+    lodash "^4.17.21"
+    object-sizeof "^1.2.0"
+
 "string-width-cjs@npm:string-width@^4.2.0":
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -9443,6 +9467,11 @@ winreg@1.2.4:
   resolved "https://registry.yarnpkg.com/winreg/-/winreg-1.2.4.tgz#ba065629b7a925130e15779108cf540990e98d1b"
   integrity sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==
 
+winreg@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/winreg/-/winreg-1.2.5.tgz#b650383e89278952494b5d113ba049a5a4fa96d8"
+  integrity sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw==
+
 word-wrap@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

Added functionality to create Steam shortcuts for games in the Hydra launcher, including asset downloads, shortcut creation, and localization support across multiple languages.

- Added `createSteamShortcut` implementation in `/src/main/events/library/create-steam-shortcut.ts` to handle Steam shortcut creation and asset management
- Added new translations for Steam shortcut functionality in `/src/locales/{en,es,pt-BR}/translation.json` with appropriate UI messages
- Added Steam-related dependencies (`steam-shortcut-editor`, `winreg`, `crc`) in `package.json` for shortcut management
- Added platform-specific check in `/src/main/main.ts` to conditionally spawn Aria2 only on non-macOS platforms
- Added Steam shortcut button in `/src/renderer/src/pages/game-details/modals/game-options-modal.tsx` with proper error handling and notifications

<sub>13 file(s) reviewed, 12 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=hydra_1689)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:02:14Z
user: login: zamitto
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-14T20:57:10Z

Review comments:
diff_hunk: @@ -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",
path: src/locales/en/translation.json
user: login: greptile-apps[bot]
body: syntax: Typo in 'innapropriate' - should be 'inappropriate'

```suggestion
    "nsfw_content_title": "This game contains inappropriate content",
```
created_at: 2025-05-13T21:59:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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<GameStats>(
+      `/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);
path: src/main/events/library/create-steam-shortcut.ts
user: login: greptile-apps[bot]
body: logic: writeSteamShortcuts error should be caught and logged
created_at: 2025-05-13T22:00:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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<GameStats>(
+      `/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);
+  }
+};
path: src/main/events/library/create-steam-shortcut.ts
user: login: greptile-apps[bot]
body: style: Missing return value when game is not found - should return explicit null/undefined
created_at: 2025-05-13T22:00:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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),
path: src/preload/index.ts
user: login: greptile-apps[bot]
body: logic: Parameter mismatch with `createGameShortcut` on line 129. Consider whether `createSteamShortcut` should also accept a `location` parameter for consistency.
created_at: 2025-05-13T22:00:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,6 +17,36 @@ export interface SteamAppDetailsResponse {
   };
 }
 
+export const getSteamLocation = async (): Promise<string> => {
+  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);
+    });
+  });
path: src/main/services/steam.ts
user: login: greptile-apps[bot]
body: logic: Missing return type on Promise. Also need to handle case where value is undefined before accessing value.value

```suggestion
  return new Promise<string>((resolve, reject) => {
    regKey.get("SteamPath", (err, value) => {
      if (err) {
        reject(err);
        return;
      }
      if (!value) {
        reject(new Error("Steam registry key not found"));
        return;
      }
      resolve(value.value);
    });
  });
```
created_at: 2025-05-13T22:00:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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) {
path: src/main/services/steam.ts
user: login: greptile-apps[bot]
body: logic: Unsafe assumption that first directory is the active Steam user. Steam can have multiple user directories. Should handle multiple users case
created_at: 2025-05-13T22:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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[];
+};
path: src/main/services/steam.ts
user: login: greptile-apps[bot]
body: style: No error handling for missing shortcuts.vdf file or invalid file contents. Should wrap in try/catch
created_at: 2025-05-13T22:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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
+  );
path: src/main/services/steam.ts
user: login: greptile-apps[bot]
body: style: writeFileSync can throw - should use try/catch and handle errors gracefully. Consider using writeFile with async/await instead
created_at: 2025-05-13T22:00:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,3 +76,89 @@ export const getSteamAppDetails = async (
       return null;
     });
 };
+
+export const getSteamUserId = () => {
+  const userDataPath = path.join(
+    SystemPath.getPath("appData"),
+    "Steam",
+    "userdata"
+  );
path: src/main/services/steam.ts
user: login: greptile-apps[bot]
body: logic: Path may not exist if Steam is installed in non-standard location. Need to handle this case
created_at: 2025-05-13T22:00:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -298,6 +314,10 @@ export function GameOptionsModal({
                   >
                     {t("create_shortcut")}
                   </Button>
+                  <Button onClick={handleCreateSteamShortcut} theme="outline">
+                    <SteamLogo className="game-card__shop-icon" />
+                    {t("create_steam_shortcut")}
+                  </Button>
path: src/renderer/src/pages/game-details/modals/game-options-modal.tsx
user: login: greptile-apps[bot]
body: logic: The Steam shortcut button should be disabled when game.executablePath is not set, similar to other shortcut buttons

```suggestion
                  <Button onClick={handleCreateSteamShortcut} theme="outline" disabled={!game.executablePath}>
                    <SteamLogo className="game-card__shop-icon" />
                    {t("create_steam_shortcut")}
                  </Button>
```
created_at: 2025-05-13T22:01:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -298,6 +314,10 @@ export function GameOptionsModal({
                   >
                     {t("create_shortcut")}
                   </Button>
+                  <Button onClick={handleCreateSteamShortcut} theme="outline">
+                    <SteamLogo className="game-card__shop-icon" />
path: src/renderer/src/pages/game-details/modals/game-options-modal.tsx
user: login: greptile-apps[bot]
body: style: The class 'game-card__shop-icon' appears to be from a different component. Should use a class specific to this modal
created_at: 2025-05-13T22:01:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,3 +53,22 @@ export interface SteamAppDetails {
     ids: number[];
   };
 }
+
+export interface SteamShortcut {
+  appid: number;
+  appname: string;
+  Exe: string;
+  StartDir: string;
+  icon: string;
+  ShortcutPath: string;
+  LaunchOptions: string;
+  IsHidden: boolean;
+  AllowDesktopConfig: boolean;
+  AllowOverlay: boolean;
+  OpenVR: boolean;
+  Devkit: boolean;
+  DevkitGameID: string;
+  DevkitOverrideAppID: boolean;
+  LastPlayTime: boolean;
path: src/types/steam.types.ts
user: login: greptile-apps[bot]
body: logic: LastPlayTime is defined as boolean but should likely be a number (Unix timestamp) or string (ISO date) to store the actual last play time

```suggestion
  LastPlayTime: number;
```
created_at: 2025-05-13T22:01:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-14T20:53:49Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=hydralauncher_hydra&pullRequest=1689) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=hydralauncher_hydra&pullRequest=1689&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=hydralauncher_hydra&pullRequest=1689&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=hydralauncher_hydra&pullRequest=1689&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=hydralauncher_hydra&pullRequest=1689&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=hydralauncher_hydra&pullRequest=1689&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=hydralauncher_hydra&pullRequest=1689)


author_association: NONE