Merge branch 'main' into feat/HYD-822

This commit is contained in:
Zamitto
2025-05-14 19:55:43 -03:00
17 changed files with 408 additions and 33 deletions

View File

@@ -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",

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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
);
};

View File

@@ -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">[]

View File

@@ -179,6 +179,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: (

View File

@@ -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")}

View File

@@ -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;
}