Compare commits

..

1 Commits

Author SHA1 Message Date
Moyasee
335f4d33b9 feat: implement deep link handling and game shortcut creation with icon download 2026-01-21 16:52:34 +02:00
8 changed files with 141 additions and 141 deletions

View File

@@ -64,7 +64,6 @@
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"get-port": "^7.1.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",

View File

@@ -382,9 +382,6 @@
"audio": "Audio",
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game",
"source_online": "Source is online",
"source_partial": "Some links are offline",
"source_offline": "Source is offline",
"delete_review": "Delete review",
"remove_review": "Remove Review",
"delete_review_modal_title": "Are you sure you want to delete your review?",

View File

@@ -1,12 +1,64 @@
import { registerEvent } from "../register-event";
import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path";
import fs from "node:fs";
import { app } from "electron";
import axios from "axios";
import { removeSymbolsFromName } from "@shared";
import { GameShop, ShortcutLocation } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import { SystemPath } from "@main/services/system-path";
import { windowsStartMenuPath } from "@main/constants";
import { ASSETS_PATH, windowsStartMenuPath } from "@main/constants";
import { getGameAssets } from "../catalogue/get-game-assets";
import { logger } from "@main/services";
const downloadIcon = async (
shop: GameShop,
objectId: string,
iconUrl?: string | null
): Promise<string | null> => {
const iconPath = path.join(ASSETS_PATH, `${shop}-${objectId}`, "icon.ico");
try {
if (fs.existsSync(iconPath)) {
return iconPath;
}
if (!iconUrl) {
return null;
}
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
const response = await axios.get(iconUrl, { responseType: "arraybuffer" });
fs.writeFileSync(iconPath, response.data);
return iconPath;
} catch (error) {
logger.error("Failed to download game icon", error);
return null;
}
};
const createUrlShortcut = (
shortcutPath: string,
url: string,
iconPath?: string | null
): boolean => {
try {
let content = `[InternetShortcut]\nURL=${url}\n`;
if (iconPath) {
content += `IconFile=${iconPath}\nIconIndex=0\n`;
}
fs.writeFileSync(shortcutPath, content);
return true;
} catch (error) {
logger.error("Failed to create URL shortcut", error);
return false;
}
};
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
@@ -17,30 +69,42 @@ const createGameShortcut = async (
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
const filePath = game.executablePath;
const windowVbsPath = app.isPackaged
? path.join(process.resourcesPath, "windows.vbs")
: undefined;
const options = {
filePath,
name: removeSymbolsFromName(game.title),
outputPath:
location === "desktop"
? SystemPath.getPath("desktop")
: windowsStartMenuPath,
};
return createDesktopShortcut({
windows: { ...options, VBScriptPath: windowVbsPath },
linux: options,
osx: options,
});
if (!game) {
return false;
}
return false;
const shortcutName = removeSymbolsFromName(game.title);
const deepLink = `hydralauncher://run?shop=${shop}&objectId=${objectId}`;
const outputPath =
location === "desktop"
? SystemPath.getPath("desktop")
: windowsStartMenuPath;
const assets = shop === "custom" ? null : await getGameAssets(objectId, shop);
const iconPath = await downloadIcon(shop, objectId, assets?.iconUrl);
if (process.platform === "win32") {
const shortcutPath = path.join(outputPath, `${shortcutName}.url`);
return createUrlShortcut(shortcutPath, deepLink, iconPath);
}
const windowVbsPath = app.isPackaged
? path.join(process.resourcesPath, "windows.vbs")
: undefined;
const options = {
filePath: process.execPath,
arguments: deepLink,
name: shortcutName,
outputPath,
icon: iconPath ?? undefined,
};
return createDesktopShortcut({
windows: { ...options, VBScriptPath: windowVbsPath },
linux: options,
osx: options,
});
};
registerEvent("createGameShortcut", createGameShortcut);

View File

@@ -1,8 +1,9 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { app, BrowserWindow, net, protocol, shell } from "electron";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { spawn } from "node:child_process";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import {
logger,
@@ -13,7 +14,10 @@ import {
} from "@main/services";
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, levelKeys } from "./level";
import { db, gamesSublevel, levelKeys } from "./level";
import { GameShop } from "@types";
import { parseExecutablePath } from "./events/helpers/parse-executable-path";
import { parseLaunchOptions } from "./events/helpers/parse-launch-options";
import { loadState } from "./main";
const { autoUpdater } = updater;
@@ -146,18 +150,61 @@ app.whenReady().then(async () => {
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith("hydralauncher://")
);
if (deepLinkArg) {
handleDeepLinkPath(deepLinkArg);
}
});
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
const handleRunGame = async (shop: GameShop, objectId: string) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game?.executablePath) {
logger.error("Game not found or no executable path", { shop, objectId });
return;
}
const parsedPath = parseExecutablePath(game.executablePath);
const parsedParams = parseLaunchOptions(game.launchOptions);
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
});
if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;
}
spawn(parsedPath, parsedParams, { shell: false, detached: true });
};
const handleDeepLinkPath = (uri?: string) => {
if (!uri) return;
try {
const url = new URL(uri);
if (url.host === "run") {
const shop = url.searchParams.get("shop") as GameShop | null;
const objectId = url.searchParams.get("objectId");
if (shop && objectId) {
handleRunGame(shop, objectId);
}
return;
}
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
return;

View File

@@ -1,6 +1,5 @@
import axios from "axios";
import http from "node:http";
import getPort, { portNumbers } from "get-port";
import cp from "node:child_process";
import fs from "node:fs";
@@ -28,17 +27,11 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
win32: "hydra-python-rpc.exe",
};
const RPC_PORT_RANGE_START = 8080;
const RPC_PORT_RANGE_END = 9000;
const DEFAULT_RPC_PORT = 8084;
const HEALTH_CHECK_INTERVAL_MS = 100;
const HEALTH_CHECK_TIMEOUT_MS = 10000;
export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881";
public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
baseURL: `http://localhost:${this.RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
@@ -69,46 +62,15 @@ export class PythonRPC {
return newPassword;
}
private static async waitForHealthCheck(): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await this.rpc.get("/healthcheck", { timeout: 1000 });
if (response.status === 200) {
pythonRpcLogger.log("RPC health check passed");
return;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)
);
}
throw new Error("RPC health check timed out");
}
public static async spawn(
initialDownload?: GamePayload,
initialSeeding?: GamePayload[]
) {
const rpcPassword = await this.getRPCPassword();
const port = await getPort({
port: [
DEFAULT_RPC_PORT,
...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END),
],
});
this.rpc.defaults.baseURL = `http://localhost:${port}`;
pythonRpcLogger.log(`Using RPC port: ${port}`);
const commonArgs = [
this.BITTORRENT_PORT,
String(port),
this.RPC_PORT,
rpcPassword,
initialDownload ? JSON.stringify(initialDownload) : "",
initialSeeding ? JSON.stringify(initialSeeding) : "",
@@ -129,7 +91,6 @@ export class PythonRPC {
);
app.quit();
return;
}
const childProcess = cp.spawn(binaryPath, commonArgs, {
@@ -138,6 +99,7 @@ export class PythonRPC {
});
this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess;
} else {
const scriptPath = path.join(
@@ -153,23 +115,11 @@ export class PythonRPC {
});
this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess;
}
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
try {
await this.waitForHealthCheck();
pythonRpcLogger.log(`Python RPC started successfully on port ${port}`);
} catch (err) {
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
dialog.showErrorBox(
"RPC Error",
`Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.`
);
this.kill();
throw err;
}
}
public static kill() {

View File

@@ -40,34 +40,6 @@
gap: calc(globals.$spacing-unit * 1);
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 2);
padding-right: calc(globals.$spacing-unit * 4);
position: relative;
}
&__availability-orb {
position: absolute;
top: calc(globals.$spacing-unit * 1.5);
right: calc(globals.$spacing-unit * 1.5);
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--online {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
&--partial {
background-color: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
}
&--offline {
background-color: #ef4444;
opacity: 0.7;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
}
}
&__repack-title {

View File

@@ -6,7 +6,6 @@ import {
ChevronDownIcon,
ChevronUpIcon,
} from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import {
Badge,
@@ -186,20 +185,6 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
const getRepackAvailabilityStatus = (
repack: GameRepack
): "online" | "partial" | "offline" => {
const unavailableSet = new Set(repack.unavailableUris ?? []);
const availableCount = repack.uris.filter(
(uri) => !unavailableSet.has(uri)
).length;
const unavailableCount = repack.uris.length - availableCount;
if (unavailableCount === 0) return "online";
if (availableCount === 0) return "offline";
return "partial";
};
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
@@ -378,8 +363,6 @@ export function RepacksModal({
filteredRepacks.map((repack) => {
const isLastDownloadedOption =
checkIfLastDownloadedOption(repack);
const availabilityStatus = getRepackAvailabilityStatus(repack);
const tooltipId = `availability-orb-${repack.id}`;
return (
<Button
@@ -388,13 +371,6 @@ export function RepacksModal({
onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button"
>
<span
className={`repacks-modal__availability-orb repacks-modal__availability-orb--${availabilityStatus}`}
data-tooltip-id={tooltipId}
data-tooltip-content={t(`source_${availabilityStatus}`)}
/>
<Tooltip id={tooltipId} />
<p className="repacks-modal__repack-title">
{repack.title}
{userPreferences?.enableNewDownloadOptionsBadges !==

View File

@@ -5587,11 +5587,6 @@ get-nonce@^1.0.0:
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-port@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec"
integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==
get-proto@^1.0.0, get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"