fix: moving downloader directly to parser

This commit is contained in:
Hydra 2024-05-04 15:09:43 +01:00
parent 4d32ff2ac2
commit 866ee7b30d
21 changed files with 1207 additions and 1463 deletions

View File

@ -18,7 +18,6 @@ import "./library/open-game";
import "./library/open-game-installer"; import "./library/open-game-installer";
import "./library/remove-game"; import "./library/remove-game";
import "./library/remove-game-from-library"; import "./library/remove-game-from-library";
import "./misc/get-or-cache-image";
import "./misc/open-external"; import "./misc/open-external";
import "./misc/show-open-dialog"; import "./misc/show-open-dialog";
import "./torrenting/cancel-game-download"; import "./torrenting/cancel-game-download";

View File

@ -3,7 +3,7 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { getImageBase64 } from "@main/helpers"; import { getFileBase64 } from "@main/helpers";
import { getSteamGameIconUrl } from "@main/services"; import { getSteamGameIconUrl } from "@main/services";
const addGameToLibrary = async ( const addGameToLibrary = async (
@ -31,7 +31,7 @@ const addGameToLibrary = async (
} }
); );
} else { } else {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID)); const iconUrl = await getFileBase64(await getSteamGameIconUrl(objectID));
return gameRepository.insert({ return gameRepository.insert({
title, title,

View File

@ -1,40 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { registerEvent } from "../register-event";
import { getFileBuffer } from "@main/helpers";
import { logger } from "@main/services";
import { imageCachePath } from "@main/constants";
const getOrCacheImage = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
if (!fs.existsSync(imageCachePath)) fs.mkdirSync(imageCachePath);
const extname = path.extname(url);
const checksum = crypto.createHash("sha256").update(url).digest("hex");
const cachePath = path.join(imageCachePath, `${checksum}${extname}`);
const cache = fs.existsSync(cachePath);
if (cache) return `hydra://${cachePath}`;
getFileBuffer(url).then((buffer) =>
fs.writeFile(cachePath, buffer, (err) => {
if (err) {
logger.error(`Failed to cache image`, err, {
method: "getOrCacheImage",
});
}
})
);
return url;
};
registerEvent(getOrCacheImage, {
name: "getOrCacheImage",
});

View File

@ -5,7 +5,7 @@ import { GameStatus } from "@main/constants";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { getImageBase64 } from "@main/helpers"; import { getFileBase64 } from "@main/helpers";
import { In } from "typeorm"; import { In } from "typeorm";
const startGameDownload = async ( const startGameDownload = async (
@ -72,7 +72,7 @@ const startGameDownload = async (
return game; return game;
} else { } else {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID)); const iconUrl = await getFileBase64(await getSteamGameIconUrl(objectID));
const createdGame = await gameRepository.save({ const createdGame = await gameRepository.save({
title, title,

View File

@ -79,10 +79,24 @@ export const getFileBuffer = async (url: string) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer)) response.arrayBuffer().then((buffer) => Buffer.from(buffer))
); );
export const getImageBase64 = async (url: string) => export const getFileBase64 = async (url: string) =>
getFileBuffer(url).then((buffer) => { fetch(url, { method: "GET" }).then((response) =>
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`; response.arrayBuffer().then((buffer) => {
}); const base64 = Buffer.from(buffer).toString("base64");
const contentType = response.headers.get("content-type");
return `data:${contentType};base64,${base64}`;
})
);
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export * from "./formatters"; export * from "./formatters";
export * from "./ps"; export * from "./ps";

View File

@ -1,6 +1,5 @@
import path from "node:path"; import path from "node:path";
import cp from "node:child_process"; import cp from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import * as Sentry from "@sentry/electron/main"; import * as Sentry from "@sentry/electron/main";
import { Notification, app, dialog } from "electron"; import { Notification, app, dialog } from "electron";
@ -99,25 +98,6 @@ export class TorrentClient {
return game.progress; return game.progress;
} }
private static createTempIcon(encodedIcon: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.randomBytes(16).toString("hex");
const iconPath = path.join(app.getPath("temp"), `${hash}.png`);
fs.writeFile(
iconPath,
Buffer.from(
encodedIcon.replace("data:image/jpeg;base64,", ""),
"base64"
),
(err) => {
if (err) reject(err);
resolve(iconPath);
}
);
});
}
public static async onSocketData(data: Buffer) { public static async onSocketData(data: Buffer) {
const message = Buffer.from(data).toString("utf-8"); const message = Buffer.from(data).toString("utf-8");
@ -159,10 +139,7 @@ export class TorrentClient {
}); });
if (userPreferences?.downloadNotificationsEnabled) { if (userPreferences?.downloadNotificationsEnabled) {
const iconPath = await this.createTempIcon(game.iconUrl);
new Notification({ new Notification({
icon: iconPath,
title: t("download_complete", { title: t("download_complete", {
ns: "notifications", ns: "notifications",
lng: userPreferences.language, lng: userPreferences.language,

View File

@ -1,4 +1,4 @@
import { BrowserWindow, Menu, Tray, app } from "electron"; import { BrowserWindow, Menu, Notification, Tray, app } from "electron";
import { is } from "@electron-toolkit/utils"; import { is } from "@electron-toolkit/utils";
import { t } from "i18next"; import { t } from "i18next";
import path from "node:path"; import path from "node:path";
@ -54,6 +54,10 @@ export class WindowManager {
where: { id: 1 }, where: { id: 1 },
}); });
this.mainWindow.on("ready-to-show", () => {
if (!app.isPackaged) WindowManager.mainWindow?.webContents.openDevTools();
});
this.mainWindow.on("close", () => { this.mainWindow.on("close", () => {
if (userPreferences?.preferQuitInsteadOfHiding) { if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit(); app.quit();

View File

@ -1,13 +1,16 @@
import { parentPort } from "worker_threads"; import { parentPort } from "worker_threads";
import parseTorrent from "parse-torrent"; import parseTorrent from "parse-torrent";
import { getFileBuffer } from "@main/helpers";
const port = parentPort; const port = parentPort;
if (!port) throw new Error("IllegalState"); if (!port) throw new Error("IllegalState");
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
port.on("message", async (url: string) => { port.on("message", async (url: string) => {
const buffer = await getFileBuffer(url); const buffer = await getFileBuffer(url);
const torrent = await parseTorrent(buffer); const torrent = await parseTorrent(buffer);
port.postMessage(torrent); port.postMessage(torrent);
}); });

View File

@ -94,7 +94,6 @@ contextBridge.exposeInMainWorld("electron", {
getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"), getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
/* Misc */ /* Misc */
getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
ping: () => ipcRenderer.invoke("ping"), ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"), getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"), getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),

View File

@ -104,7 +104,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getDiskFreeSpace", path), ipcRenderer.invoke("getDiskFreeSpace", path),
/* Misc */ /* Misc */
getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
ping: () => ipcRenderer.invoke("ping"), ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"), getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"), getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: hydra: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -1,29 +0,0 @@
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} />;
}
);
AsyncImage.displayName = "AsyncImage";

View File

@ -4,8 +4,6 @@ import type { CatalogueEntry } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg?react"; import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg?react";
import { AsyncImage } from "../async-image/async-image";
import * as styles from "./game-card.css"; import * as styles from "./game-card.css";
import { useAppSelector } from "@renderer/hooks"; import { useAppSelector } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -43,11 +41,7 @@ export function GameCard({ game, disabled, ...props }: GameCardProps) {
disabled={disabled} disabled={disabled}
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<AsyncImage <img src={game.cover} alt={game.title} className={styles.cover} />
src={game.cover}
alt={game.title}
className={styles.cover}
/>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.titleContainer}> <div className={styles.titleContainer}>

View File

@ -1,5 +1,4 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AsyncImage } from "@renderer/components";
import * as styles from "./hero.css"; import * as styles from "./hero.css";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ShopDetails } from "@types"; import { ShopDetails } from "@types";
@ -35,14 +34,14 @@ export function Hero() {
className={styles.hero} className={styles.hero}
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<AsyncImage <img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg" src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
alt={featuredGameDetails?.name} alt={featuredGameDetails?.name}
className={styles.heroMedia} className={styles.heroMedia}
/> />
<div className={styles.content}> <div className={styles.content}>
<AsyncImage <img
src={steamUrlBuilder.logo(FEATURED_GAME_ID)} src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
width="250px" width="250px"
alt={featuredGameDetails?.name} alt={featuredGameDetails?.name}

View File

@ -5,6 +5,5 @@ export * from "./header/header";
export * from "./hero/hero"; export * from "./hero/hero";
export * from "./modal/modal"; export * from "./modal/modal";
export * from "./sidebar/sidebar"; export * from "./sidebar/sidebar";
export * from "./async-image/async-image";
export * from "./text-field/text-field"; export * from "./text-field/text-field";
export * from "./checkbox-field/checkbox-field"; export * from "./checkbox-field/checkbox-field";

View File

@ -74,7 +74,6 @@ export const menuItem = recipe({
active: { active: {
true: { true: {
backgroundColor: "rgba(255, 255, 255, 0.1)", backgroundColor: "rgba(255, 255, 255, 0.1)",
fontWeight: "bold",
}, },
}, },
muted: { muted: {
@ -97,11 +96,6 @@ export const menuItemButton = style({
overflow: "hidden", overflow: "hidden",
width: "100%", width: "100%",
padding: `9px ${SPACING_UNIT}px`, padding: `9px ${SPACING_UNIT}px`,
selectors: {
[`${menuItem({ active: true }).split(" ")[1]} &`]: {
fontWeight: "bold",
},
},
}); });
export const menuItemButtonLabel = style({ export const menuItemButtonLabel = style({

View File

@ -4,7 +4,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import type { Game } from "@types"; import type { Game } from "@types";
import { AsyncImage, TextField } from "@renderer/components"; import { TextField } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary } from "@renderer/hooks";
import { routes } from "./routes"; import { routes } from "./routes";
@ -14,7 +14,6 @@ import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
import XLogo from "@renderer/assets/x-icon.svg?react"; import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css"; import * as styles from "./sidebar.css";
import { vars } from "@renderer/theme.css";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_INITIAL_WIDTH = 250;
@ -217,7 +216,11 @@ export function Sidebar() {
) )
} }
> >
<AsyncImage className={styles.gameIcon} src={game.iconUrl} /> <img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
<span className={styles.menuItemButtonLabel}> <span className={styles.menuItemButtonLabel}>
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>

View File

@ -79,7 +79,6 @@ declare global {
getDiskFreeSpace: (path: string) => Promise<DiskSpace>; getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
/* Misc */ /* Misc */
getOrCacheImage: (url: string) => Promise<string>;
openExternal: (src: string) => Promise<void>; openExternal: (src: string) => Promise<void>;
getVersion: () => Promise<string>; getVersion: () => Promise<string>;
ping: () => string; ping: () => string;

View File

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AsyncImage, Button, TextField } from "@renderer/components"; import { Button, TextField } from "@renderer/components";
import { formatDownloadProgress, steamUrlBuilder } from "@renderer/helpers"; import { formatDownloadProgress, steamUrlBuilder } from "@renderer/helpers";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game } from "@types"; import type { Game } from "@types";
@ -235,7 +235,7 @@ export function Downloads() {
cancelled: game.status === "cancelled", cancelled: game.status === "cancelled",
})} })}
> >
<AsyncImage <img
src={steamUrlBuilder.library(game.objectID)} src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCover} className={styles.downloadCover}
alt={game.title} alt={game.title}

View File

@ -12,7 +12,7 @@ import type {
SteamAppDetails, SteamAppDetails,
} from "@types"; } from "@types";
import { AsyncImage, Button } from "@renderer/components"; import { Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers"; import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks"; import { useAppDispatch, useDownload } from "@renderer/hooks";
@ -69,14 +69,16 @@ export function GameDetails() {
const { game: gameDownloading, startDownload, isDownloading } = useDownload(); const { game: gameDownloading, startDownload, isDownloading } = useDownload();
const handleImageSettled = useCallback((url: string) => { const heroImage = steamUrlBuilder.libraryHero(objectID!);
average(url, { amount: 1, format: "hex" })
const handleHeroLoad = () => {
average(heroImage, { amount: 1, format: "hex" })
.then((color) => { .then((color) => {
const darkColor = new Color(color).darken(0.6).toString() as string; const darkColor = new Color(color).darken(0.6).toString() as string;
setColor({ light: color as string, dark: darkColor }); setColor({ light: color as string, dark: darkColor });
}) })
.catch(() => {}); .catch(() => {});
}, []); };
const getGame = useCallback(() => { const getGame = useCallback(() => {
window.electron window.electron
@ -218,15 +220,15 @@ export function GameDetails() {
) : ( ) : (
<section className={styles.container}> <section className={styles.container}>
<div className={styles.hero}> <div className={styles.hero}>
<AsyncImage <img
src={steamUrlBuilder.libraryHero(objectID!)} src={heroImage}
className={styles.heroImage} className={styles.heroImage}
alt={game?.title} alt={game?.title}
onSettled={handleImageSettled} onLoad={handleHeroLoad}
/> />
<div className={styles.heroBackdrop}> <div className={styles.heroBackdrop}>
<div className={styles.heroContent}> <div className={styles.heroContent}>
<AsyncImage <img
src={steamUrlBuilder.logo(objectID!)} src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }} style={{ width: 300, alignSelf: "flex-end" }}
/> />

2478
yarn.lock

File diff suppressed because it is too large Load Diff