Merge pull request #575 from hydralauncher/feature/game-options-modal

feat: game options modal
This commit is contained in:
Chubby Granny Chaser 2024-06-08 20:30:09 +01:00 committed by GitHub
commit 4b97639972
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 703 additions and 309 deletions

View File

@ -44,6 +44,7 @@ jobs:
name: Build-${{ matrix.os }} name: Build-${{ matrix.os }}
path: | path: |
dist/win-unpacked/** dist/win-unpacked/**
dist/*-portable.exe
dist/*.zip dist/*.zip
dist/*.dmg dist/*.dmg
dist/*.deb dist/*.deb

View File

@ -18,6 +18,9 @@ asarUnpack:
win: win:
executableName: Hydra executableName: Hydra
requestedExecutionLevel: requireAdministrator requestedExecutionLevel: requireAdministrator
target:
- nsis
- portable
nsis: nsis:
artifactName: ${name}-${version}-setup.${ext} artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName} shortcutName: ${productName}
@ -25,6 +28,9 @@ nsis:
createDesktopShortcut: always createDesktopShortcut: always
oneClick: false oneClick: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
portable:
artifactName: ${name}-${version}-portable.${ext}
requestExecutionLevel: admin
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:

View File

@ -48,6 +48,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"color": "^4.2.3", "color": "^4.2.3",
"color.js": "^1.2.0", "color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"electron-log": "^5.1.4", "electron-log": "^5.1.4",
"electron-updater": "^6.1.8", "electron-updater": "^6.1.8",

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,8 @@
"downloading": "{{title}} ({{percentage}} - Downloading…)", "downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library", "filter": "Filter library",
"home": "Home", "home": "Home",
"queued": "{{title}} (Queued)" "queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected"
}, },
"header": { "header": {
"search": "Search games", "search": "Search games",
@ -100,7 +101,25 @@
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "Open screenshot {{number}}", "open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings", "download_settings": "Download settings",
"downloader": "Downloader" "downloader": "Downloader",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
"open_download_location": "See downloaded files",
"create_shortcut": "Create desktop shortcut",
"remove_files": "Remove files",
"remove_from_library_title": "Are you sure?",
"remove_from_library_description": "This will remove {{game}} from your library",
"options": "Options",
"executable_section_title": "Executable",
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Check out updates or other versions of this game",
"danger_zone_section_title": "Danger zone",
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",

View File

@ -16,7 +16,8 @@
"filter": "Filtrar biblioteca", "filter": "Filtrar biblioteca",
"home": "Início", "home": "Início",
"follow_us": "Acompanhe-nos", "follow_us": "Acompanhe-nos",
"queued": "{{title}} (Na fila)" "queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado"
}, },
"header": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
@ -97,7 +98,25 @@
"screenshot": "Captura de tela {{number}}", "screenshot": "Captura de tela {{number}}",
"open_screenshot": "Ver captura de tela {{number}}", "open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download", "download_settings": "Ajustes do download",
"downloader": "Downloader" "downloader": "Downloader",
"select_executable": "Selecionar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos",
"options": "Opções",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
"executable_section_description": "O caminho do arquivo que será executado ao clicar em \"Jogar\"",
"downloads_secion_title": "Downloads",
"downloads_section_description": "Confira atualizações ou versões diferentes para este mesmo título",
"danger_zone_section_title": "Zona de perigo",
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",

View File

@ -10,12 +10,16 @@ import "./catalogue/search-games";
import "./catalogue/search-game-repacks"; import "./catalogue/search-game-repacks";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./library/add-game-to-library"; import "./library/add-game-to-library";
import "./library/create-game-shortcut";
import "./library/close-game"; import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
import "./library/get-game-by-object-id"; import "./library/get-game-by-object-id";
import "./library/get-library"; import "./library/get-library";
import "./library/open-game"; import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer"; import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game"; import "./library/remove-game";
import "./library/remove-game-from-library"; import "./library/remove-game-from-library";
import "./misc/open-external"; import "./misc/open-external";

View File

@ -11,8 +11,7 @@ const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
objectID: string, objectID: string,
title: string, title: string,
shop: GameShop, shop: GameShop
executablePath: string | null
) => { ) => {
return gameRepository return gameRepository
.update( .update(
@ -22,7 +21,6 @@ const addGameToLibrary = async (
{ {
shop, shop,
status: null, status: null,
executablePath,
isDeleted: false, isDeleted: false,
} }
) )
@ -42,7 +40,6 @@ const addGameToLibrary = async (
iconUrl, iconUrl,
objectID, objectID,
shop, shop,
executablePath,
}) })
.then(() => { .then(() => {
if (iconUrl) { if (iconUrl) {

View File

@ -0,0 +1,29 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
id: number
): Promise<boolean> => {
const game = await gameRepository.findOne({
where: { id, executablePath: Not(IsNull()) },
});
if (game) {
const filePath = game.executablePath;
const options = { filePath, name: game.title };
return createDesktopShortcut({
windows: options,
linux: options,
osx: options,
});
}
return false;
};
registerEvent("createGameShortcut", createGameShortcut);

View File

@ -0,0 +1,18 @@
import { shell } from "electron";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const openGameExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.executablePath) return;
shell.showItemInFolder(game.executablePath);
};
registerEvent("openGameExecutablePath", openGameExecutablePath);

View File

@ -0,0 +1,27 @@
import { shell } from "electron";
import path from "node:path";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
const openGameInstallerPath = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.folderName || !game.downloadPath) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName!
);
shell.showItemInFolder(gamePath);
return true;
};
registerEvent("openGameInstallerPath", openGameInstallerPath);

View File

@ -5,7 +5,10 @@ const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number gameId: number
) => { ) => {
gameRepository.update({ id: gameId }, { isDeleted: true }); gameRepository.update(
{ id: gameId },
{ isDeleted: true, executablePath: null }
);
}; };
registerEvent("removeGameFromLibrary", removeGameFromLibrary); registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View File

@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
id: number,
executablePath: string
) => {
return gameRepository.update(
{
id,
},
{
executablePath,
}
);
};
registerEvent("updateExecutablePath", updateExecutablePath);

View File

@ -61,22 +61,19 @@ contextBridge.exposeInMainWorld("electron", {
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
objectID: string, ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
title: string, createGameShortcut: (id: number) =>
shop: GameShop, ipcRenderer.invoke("createGameShortcut", id),
executablePath: string updateExecutablePath: (id: number, executablePath: string) =>
) => ipcRenderer.invoke("updateExecutablePath", id, executablePath),
ipcRenderer.invoke(
"addGameToLibrary",
objectID,
title,
shop,
executablePath
),
getLibrary: () => ipcRenderer.invoke("getLibrary"), getLibrary: () => ipcRenderer.invoke("getLibrary"),
openGameInstaller: (gameId: number) => openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId), ipcRenderer.invoke("openGameInstaller", gameId),
openGameInstallerPath: (gameId: number) =>
ipcRenderer.invoke("openGameInstallerPath", gameId),
openGameExecutablePath: (gameId: number) =>
ipcRenderer.invoke("openGameExecutablePath", gameId),
openGame: (gameId: number, executablePath: string) => openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath), ipcRenderer.invoke("openGame", gameId, executablePath),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId), closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),

View File

@ -55,4 +55,15 @@ export const button = styleVariants({
color: "#c0c1c7", color: "#c0c1c7",
}, },
], ],
danger: [
base,
{
border: `solid 1px #a31533`,
backgroundColor: "transparent",
color: "white",
":hover": {
backgroundColor: "#a31533",
},
},
],
}); });

View File

@ -54,7 +54,7 @@ export function Hero() {
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<img <img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg" src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
alt={FEATURED_GAME_TITLE} alt={FEATURED_GAME_TITLE}
className={styles.heroMedia} className={styles.heroMedia}
/> />

View File

@ -2,24 +2,25 @@ import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes"; import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
export const fadeIn = keyframes({ export const scaleFadeIn = keyframes({
"0%": { opacity: 0 }, "0%": { opacity: "0", scale: "0.5" },
"100%": { "100%": {
opacity: 1, opacity: "1",
scale: "1",
}, },
}); });
export const fadeOut = keyframes({ export const scaleFadeOut = keyframes({
"0%": { opacity: 1 }, "0%": { opacity: "1", scale: "1" },
"100%": { "100%": {
opacity: 0, opacity: "0",
scale: "0.5",
}, },
}); });
export const modal = recipe({ export const modal = recipe({
base: { base: {
animationName: fadeIn, animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
animationDuration: "0.3s",
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
borderRadius: "4px", borderRadius: "4px",
maxWidth: "600px", maxWidth: "600px",
@ -33,8 +34,14 @@ export const modal = recipe({
variants: { variants: {
closing: { closing: {
true: { true: {
animationName: fadeOut, animationName: scaleFadeOut,
opacity: 0, opacity: "0",
},
},
large: {
true: {
width: "800px",
maxWidth: "800px",
}, },
}, },
}, },

View File

@ -12,6 +12,7 @@ export interface ModalProps {
title: string; title: string;
description?: string; description?: string;
onClose: () => void; onClose: () => void;
large?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@ -20,6 +21,7 @@ export function Modal({
title, title,
description, description,
onClose, onClose,
large,
children, children,
}: ModalProps) { }: ModalProps) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
@ -88,7 +90,7 @@ export function Modal({
return createPortal( return createPortal(
<Backdrop isClosing={isClosing}> <Backdrop isClosing={isClosing}>
<div <div
className={styles.modal({ closing: isClosing })} className={styles.modal({ closing: isClosing, large })}
role="dialog" role="dialog"
aria-labelledby={title} aria-labelledby={title}
aria-describedby={description} aria-describedby={description}

View File

@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types"; import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components"; import { TextField } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { routes } from "./routes"; import { routes } from "./routes";
@ -36,6 +36,8 @@ export function Sidebar() {
const { lastPacket, progress } = useDownload(); const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
useEffect(() => { useEffect(() => {
updateLibrary(); updateLibrary();
}, [lastPacket?.game.id, updateLibrary]); }, [lastPacket?.game.id, updateLibrary]);
@ -122,6 +124,24 @@ export function Sidebar() {
} }
}; };
const handleSidebarGameClick = (
event: React.MouseEvent,
game: LibraryGame
) => {
const path = buildGameDetailsPath(game);
if (path !== location.pathname) {
navigate(path);
}
if (event.detail == 2) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
} else {
showWarningToast(t("game_has_no_executable"));
}
}
};
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
@ -183,9 +203,7 @@ export function Sidebar() {
<button <button
type="button" type="button"
className={styles.menuItemButton} className={styles.menuItemButton}
onClick={() => onClick={(event) => handleSidebarGameClick(event, game)}
handleSidebarItemClick(buildGameDetailsPath(game))
}
> >
{game.iconUrl ? ( {game.iconUrl ? (
<img <img

View File

@ -81,3 +81,7 @@ export const successIcon = style({
export const errorIcon = style({ export const errorIcon = style({
color: vars.color.danger, color: vars.color.danger,
}); });
export const warningIcon = style({
color: vars.color.warning,
});

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
AlertIcon,
CheckCircleFillIcon, CheckCircleFillIcon,
XCircleFillIcon, XCircleFillIcon,
XIcon, XIcon,
@ -11,7 +12,7 @@ import { SPACING_UNIT } from "@renderer/theme.css";
export interface ToastProps { export interface ToastProps {
visible: boolean; visible: boolean;
message: string; message: string;
type: "success" | "error"; type: "success" | "error" | "warning";
onClose: () => void; onClose: () => void;
} }
@ -84,6 +85,8 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
)} )}
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />} {type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
{type === "warning" && <AlertIcon className={styles.warningIcon} />}
<span style={{ fontWeight: "bold" }}>{message}</span> <span style={{ fontWeight: "bold" }}>{message}</span>
</div> </div>

View File

@ -56,11 +56,14 @@ declare global {
addGameToLibrary: ( addGameToLibrary: (
objectID: string, objectID: string,
title: string, title: string,
shop: GameShop, shop: GameShop
executablePath: string | null
) => Promise<void>; ) => Promise<void>;
createGameShortcut: (id: number) => Promise<boolean>;
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>; openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>; openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>; closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>; removeGameFromLibrary: (gameId: number) => Promise<void>;

View File

@ -29,5 +29,17 @@ export function useToast() {
[dispatch] [dispatch]
); );
return { showSuccessToast, showErrorToast }; const showWarningToast = useCallback(
(message: string) => {
dispatch(
showToast({
message,
type: "warning",
})
);
},
[dispatch]
);
return { showSuccessToast, showErrorToast, showWarningToast };
} }

View File

@ -3,7 +3,7 @@ import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers"; import { getSteamLanguage } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks"; import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types"; import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
@ -16,6 +16,7 @@ import {
RepacksModal, RepacksModal,
} from "./modals"; } from "./modals";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { GameOptionsModal } from "./modals/game-options-modal";
export interface GameDetailsContext { export interface GameDetailsContext {
game: Game | null; game: Game | null;
@ -29,6 +30,8 @@ export interface GameDetailsContext {
gameColor: string; gameColor: string;
setGameColor: React.Dispatch<React.SetStateAction<string>>; setGameColor: React.Dispatch<React.SetStateAction<string>>;
openRepacksModal: () => void; openRepacksModal: () => void;
openGameOptionsModal: () => void;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>; updateGame: () => Promise<void>;
} }
@ -44,6 +47,8 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
gameColor: "", gameColor: "",
setGameColor: () => {}, setGameColor: () => {},
openRepacksModal: () => {}, openRepacksModal: () => {},
openGameOptionsModal: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {}, updateGame: async () => {},
}); });
@ -70,6 +75,7 @@ export function GameDetailsContextProvider({
>(null); >(null);
const [isGameRunning, setisGameRunning] = useState(false); const [isGameRunning, setisGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -81,6 +87,10 @@ export function GameDetailsContextProvider({
const { startDownload, lastPacket } = useDownload(); const { startDownload, lastPacket } = useDownload();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const updateGame = useCallback(async () => { const updateGame = useCallback(async () => {
return window.electron return window.electron
.getGameByObjectID(objectID!) .getGameByObjectID(objectID!)
@ -158,6 +168,7 @@ export function GameDetailsContextProvider({
await updateGame(); await updateGame();
setShowRepacksModal(false); setShowRepacksModal(false);
setShowGameOptionsModal(false);
if ( if (
repack.repacker === "onlinefix" && repack.repacker === "onlinefix" &&
@ -172,7 +183,36 @@ export function GameDetailsContextProvider({
} }
}; };
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
};
const selectGameExecutable = async () => {
const downloadsPath = await getDownloadsPath();
return window.electron
.showOpenDialog({
properties: ["openFile"],
defaultPath: downloadsPath,
filters: [
{
name: "Game executable",
extensions: ["exe"],
},
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
return filePaths[0];
}
return null;
});
};
const openRepacksModal = () => setShowRepacksModal(true); const openRepacksModal = () => setShowRepacksModal(true);
const openGameOptionsModal = () => setShowGameOptionsModal(true);
return ( return (
<Provider <Provider
@ -188,6 +228,8 @@ export function GameDetailsContextProvider({
gameColor, gameColor,
setGameColor, setGameColor,
openRepacksModal, openRepacksModal,
openGameOptionsModal,
selectGameExecutable,
updateGame, updateGame,
}} }}
> >
@ -208,6 +250,16 @@ export function GameDetailsContextProvider({
onClose={() => setShowInstructionsModal(null)} onClose={() => setShowInstructionsModal(null)}
/> />
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
/>
)}
{children} {children}
</> </>
</Provider> </Provider>

View File

@ -1,7 +1,18 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
export const heroPanelAction = style({ export const heroPanelAction = style({
border: `solid 1px ${vars.color.muted}`, border: `solid 1px ${vars.color.muted}`,
}); });
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
});
export const separator = style({
width: "1px",
backgroundColor: vars.color.muted,
opacity: "0.2",
});

View File

@ -1,28 +1,16 @@
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react"; import { GearIcon, PlayIcon, PlusCircleIcon } from "@primer/octicons-react";
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary } from "@renderer/hooks";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css"; import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "../game-details.context"; import { gameDetailsContext } from "../game-details.context";
import { Downloader } from "@shared";
export function HeroPanelActions() { export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] = const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false); useState(false);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const { const { isGameDeleting } = useDownload();
resumeDownload,
pauseDownload,
cancelDownload,
removeGameFromLibrary,
isGameDeleting,
} = useDownload();
const { const {
game, game,
@ -31,61 +19,20 @@ export function HeroPanelActions() {
objectID, objectID,
gameTitle, gameTitle,
openRepacksModal, openRepacksModal,
openGameOptionsModal,
updateGame, updateGame,
selectGameExecutable,
} = useContext(gameDetailsContext); } = useContext(gameDetailsContext);
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateLibrary } = useLibrary(); const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const getDownloadsPath = async () => { const addGameToLibrary = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
};
const selectGameExecutable = async () => {
const downloadsPath = await getDownloadsPath();
return window.electron
.showOpenDialog({
properties: ["openFile"],
defaultPath: downloadsPath,
filters: [
{
name: "Game executable",
extensions: ["exe"],
},
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
return filePaths[0];
}
return null;
});
};
const toggleGameOnLibrary = async () => {
setToggleLibraryGameDisabled(true); setToggleLibraryGameDisabled(true);
try { try {
if (game) { await window.electron.addGameToLibrary(objectID!, gameTitle, "steam");
await removeGameFromLibrary(game.id);
} else {
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
objectID!,
gameTitle,
"steam",
gameExecutablePath
);
}
updateLibrary(); updateLibrary();
updateGame(); updateGame();
@ -94,15 +41,6 @@ export function HeroPanelActions() {
} }
}; };
const openGameInstaller = () => {
if (game) {
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
}
};
const openGame = async () => { const openGame = async () => {
if (game) { if (game) {
if (game.executablePath) { if (game.executablePath) {
@ -122,15 +60,15 @@ export function HeroPanelActions() {
const deleting = game ? isGameDeleting(game?.id) : false; const deleting = game ? isGameDeleting(game?.id) : false;
const toggleGameOnLibraryButton = ( const addGameToLibraryButton = (
<Button <Button
theme="outline" theme="outline"
disabled={toggleLibraryGameDisabled} disabled={toggleLibraryGameDisabled}
onClick={toggleGameOnLibrary} onClick={addGameToLibrary}
className={styles.heroPanelAction} className={styles.heroPanelAction}
> >
{game ? <NoEntryIcon /> : <PlusCircleIcon />} <PlusCircleIcon />
{game ? t("remove_from_library") : t("add_to_library")} {t("add_to_library")}
</Button> </Button>
); );
@ -145,109 +83,18 @@ export function HeroPanelActions() {
</Button> </Button>
); );
if (game?.status === "active" && game?.progress !== 1) {
return (
<>
<Button
onClick={() => pauseDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "removed") {
return (
<>
{showDownloadOptionsButton}
<Button
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
>
{t("remove_from_list")}
</Button>
</>
);
}
if (repacks.length && !game) { if (repacks.length && !game) {
return ( return (
<> <>
{toggleGameOnLibraryButton} {addGameToLibraryButton}
<Button {showDownloadOptionsButton}
onClick={openRepacksModal}
theme="outline"
className={styles.heroPanelAction}
>
{t("open_download_options")}
</Button>
</> </>
); );
} }
if (game) { if (game) {
return ( return (
<> <div className={styles.actions}>
{game.progress === 1 && game.downloadPath && (
<>
<BinaryNotFoundModal
visible={showBinaryNotFoundModal}
onClose={() => setShowBinaryNotFoundModal(false)}
/>
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("install")}
</Button>
</>
)}
{game.progress === 1 && !game.downloadPath && showDownloadOptionsButton}
{game.progress !== 1 && toggleGameOnLibraryButton}
{isGameRunning ? ( {isGameRunning ? (
<Button <Button
onClick={closeGame} onClick={closeGame}
@ -264,12 +111,25 @@ export function HeroPanelActions() {
disabled={deleting || isGameRunning} disabled={deleting || isGameRunning}
className={styles.heroPanelAction} className={styles.heroPanelAction}
> >
<PlayIcon />
{t("play")} {t("play")}
</Button> </Button>
)} )}
</>
<div className={styles.separator} />
<Button
onClick={openGameOptionsModal}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
<GearIcon />
{t("options")}
</Button>
</div>
); );
} }
return toggleGameOnLibraryButton; return addGameToLibraryButton;
} }

View File

@ -1,8 +1,10 @@
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css";
import { useDate } from "@renderer/hooks"; import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload } from "@renderer/hooks";
import { gameDetailsContext } from "../game-details.context"; import { gameDetailsContext } from "../game-details.context";
import { Link } from "@renderer/components";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -13,6 +15,8 @@ export function HeroPanelPlaytime() {
const { i18n, t } = useTranslation("game_details"); const { i18n, t } = useTranslation("game_details");
const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate(); const { formatDistance } = useDate();
useEffect(() => { useEffect(() => {
@ -46,8 +50,45 @@ export function HeroPanelPlaytime() {
return t("amount_hours", { amount: numberFormatter.format(hours) }); return t("amount_hours", { amount: numberFormatter.format(hours) });
}; };
if (!game?.lastTimePlayed) { if (!game) return null;
return <p>{t("not_played_yet", { title: game?.title })}</p>;
const hasDownload =
["active", "paused"].includes(game.status) && game.progress !== 1;
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;
const downloadInProgressInfo = (
<div className={styles.downloadDetailsRow}>
<Link to="/downloads" className={styles.downloadsLink}>
{game.status === "active"
? t("download_in_progress")
: t("download_paused")}
</Link>
<small>
{isGameDownloading ? progress : formatDownloadProgress(game.progress)}
</small>
</div>
);
if (!game.lastTimePlayed) {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{hasDownload && downloadInProgressInfo}
</>
);
}
if (isGameRunning) {
return (
<>
<p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo}
</>
);
} }
return ( return (
@ -58,8 +99,8 @@ export function HeroPanelPlaytime() {
})} })}
</p> </p>
{isGameRunning ? ( {hasDownload ? (
<p>{t("playing_now")}</p> downloadInProgressInfo
) : ( ) : (
<p> <p>
{t("last_time_played", { {t("last_time_played", {

View File

@ -28,9 +28,15 @@ export const actions = style({
}); });
export const downloadDetailsRow = style({ export const downloadDetailsRow = style({
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT}px`,
display: "flex", display: "flex",
alignItems: "flex-end", color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
}); });
export const progressBar = recipe({ export const progressBar = recipe({

View File

@ -1,14 +1,9 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { useContext, useMemo } from "react"; import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Color from "color"; import Color from "color";
import { useDownload } from "@renderer/hooks"; import { useDownload } from "@renderer/hooks";
import { formatDownloadProgress } from "@renderer/helpers";
import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelActions } from "./hero-panel-actions";
import { Downloader, formatBytes } from "@shared";
import * as styles from "./hero-panel.css"; import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime"; import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "../game-details.context"; import { gameDetailsContext } from "../game-details.context";
@ -18,82 +13,13 @@ export function HeroPanel() {
const { game, repacks, gameColor } = useContext(gameDetailsContext); const { game, repacks, gameColor } = useContext(gameDetailsContext);
const { progress, eta, lastPacket, isGameDeleting } = useDownload(); const { lastPacket } = useDownload();
const finalDownloadSize = useMemo(() => {
if (game?.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && game?.status === "active")
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
}, [game, lastPacket?.game]);
const isGameDownloading = const isGameDownloading =
game?.status === "active" && lastPacket?.game.id === game?.id; game?.status === "active" && lastPacket?.game.id === game?.id;
const getInfo = () => { const getInfo = () => {
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>; if (!game) {
if (game?.progress === 1) return <HeroPanelPlaytime />;
if (game?.status === "active") {
if (lastPacket?.isDownloadingMetadata && isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>{t("downloading_metadata")}</p>
</>
);
}
const sizeDownloaded = formatBytes(
lastPacket?.game?.bytesDownloaded ?? game?.bytesDownloaded
);
const showPeers =
game?.downloader === Downloader.Torrent &&
lastPacket?.numPeers !== undefined;
return (
<>
<p className={styles.downloadDetailsRow}>
{isGameDownloading
? progress
: formatDownloadProgress(game?.progress)}
<small>{eta ? t("eta", { eta }) : t("calculating_eta")}</small>
</p>
<p className={styles.downloadDetailsRow}>
<span>
{sizeDownloaded} / {finalDownloadSize}
</span>
{showPeers && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</p>
</>
);
}
if (game?.status === "paused") {
const formattedProgress = formatDownloadProgress(game.progress);
return (
<>
<p className={styles.downloadDetailsRow}>
{formattedProgress} <small>{t("paused")}</small>
</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
const [latestRepack] = repacks; const [latestRepack] = repacks;
if (latestRepack) { if (latestRepack) {
@ -109,6 +35,9 @@ export function HeroPanel() {
} }
return <p>{t("no_downloads")}</p>; return <p>{t("no_downloads")}</p>;
}
return <HeroPanelPlaytime />;
}; };
const backgroundColor = gameColor const backgroundColor = gameColor

View File

@ -0,0 +1,24 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css";
export const optionsContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "column",
});
export const gameOptionHeader = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const gameOptionHeaderDescription = style({
fontFamily: "'Fira Sans', sans-serif",
fontWeight: "400",
});
export const gameOptionRow = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,192 @@
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "../game-details.context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
export interface GameOptionsModalProps {
visible: boolean;
game: Game;
onClose: () => void;
}
export function GameOptionsModal({
visible,
game,
onClose,
}: GameOptionsModalProps) {
const { t } = useTranslation("game_details");
const { updateGame, openRepacksModal, selectGameExecutable } =
useContext(gameDetailsContext);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const {
removeGameInstaller,
removeGameFromLibrary,
isGameDeleting,
cancelDownload,
} = useDownload();
const deleting = isGameDeleting(game.id);
const { lastPacket } = useDownload();
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;
const handleRemoveGameFromLibrary = async () => {
if (isGameDownloading) {
await cancelDownload(game.id);
}
await removeGameFromLibrary(game.id);
updateGame();
onClose();
};
const handleChangeExecutableLocation = async () => {
const path = await selectGameExecutable();
if (path) {
await window.electron.updateExecutablePath(game.id, path);
updateGame();
}
};
const handleCreateShortcut = async () => {
await window.electron.createGameShortcut(game.id);
};
const handleOpenDownloadFolder = async () => {
await window.electron.openGameInstallerPath(game.id);
};
const handleDeleteGame = async () => {
await removeGameInstaller(game.id);
updateGame();
};
const handleOpenGameExecutablePath = async () => {
await window.electron.openGameExecutablePath(game.id);
};
return (
<>
<DeleteGameModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
deleteGame={handleDeleteGame}
/>
<RemoveGameFromLibraryModal
visible={showRemoveGameModal}
onClose={() => setShowRemoveGameModal(false)}
removeGameFromLibrary={handleRemoveGameFromLibrary}
game={game}
/>
<Modal
visible={visible}
title={game.title}
onClose={onClose}
large={true}
>
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("executable_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("executable_section_description")}
</h4>
</div>
<div className={styles.gameOptionRow}>
<TextField
value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_executable_selected")}
/>
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
{t("select_executable")}
</Button>
</div>
{game.executablePath && (
<div className={styles.gameOptionRow}>
<Button
type="button"
theme="outline"
onClick={handleOpenGameExecutablePath}
>
{t("open_folder")}
</Button>
<Button onClick={handleCreateShortcut} theme="outline">
{t("create_shortcut")}
</Button>
</div>
)}
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("downloads_section_description")}
</h4>
</div>
<div className={styles.gameOptionRow}>
<Button
onClick={openRepacksModal}
theme="outline"
disabled={deleting || isGameDownloading}
>
{t("open_download_options")}
</Button>
{game.downloadPath && (
<Button
onClick={handleOpenDownloadFolder}
theme="outline"
disabled={deleting}
>
{t("open_download_location")}
</Button>
)}
</div>
<div className={styles.gameOptionHeader}>
<h2>{t("danger_zone_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("danger_zone_section_description")}
</h4>
</div>
<div className={styles.gameOptionRow}>
<Button
onClick={() => setShowRemoveGameModal(true)}
theme="danger"
disabled={deleting}
>
{t("remove_from_library")}
</Button>
<Button
onClick={() => {
setShowDeleteModal(true);
}}
theme="danger"
disabled={isGameDownloading || deleting || !game.downloadPath}
>
{t("remove_files")}
</Button>
</div>
</div>
</Modal>
</>
);
}

View File

@ -0,0 +1,10 @@
import { SPACING_UNIT } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const deleteActionsButtonsCtn = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import * as styles from "./remove-from-library-modal.css";
import { Game } from "@types";
interface RemoveGameFromLibraryModalProps {
visible: boolean;
game: Game;
onClose: () => void;
removeGameFromLibrary: () => Promise<void>;
}
export function RemoveGameFromLibraryModal({
onClose,
game,
visible,
removeGameFromLibrary,
}: RemoveGameFromLibraryModalProps) {
const { t } = useTranslation("game_details");
const handleRemoveGame = async () => {
await removeGameFromLibrary();
onClose();
};
return (
<Modal
visible={visible}
title={t("remove_from_library_title")}
description={t("remove_from_library_description", { game: game.title })}
onClose={onClose}
>
<div className={styles.deleteActionsButtonsCtn}>
<Button onClick={handleRemoveGame} theme="outline">
{t("remove")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</Modal>
);
}

View File

@ -1,7 +1,8 @@
import { useContext, useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Button, Modal, TextField } from "@renderer/components"; import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css"; import * as styles from "./repacks-modal.css";
@ -31,13 +32,22 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null); const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const { repacks } = useContext(gameDetailsContext); const [infoHash, setInfoHash] = useState("");
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const getInfoHash = useCallback(async () => {
const torrent = await parseTorrent(game?.uri ?? "");
setInfoHash(torrent.infoHash ?? "");
}, [game]);
useEffect(() => { useEffect(() => {
setFilteredRepacks(repacks); setFilteredRepacks(repacks);
}, [repacks, visible]);
if (game?.uri) getInfoHash();
}, [repacks, visible, game, getInfoHash]);
const handleRepackClick = (repack: GameRepack) => { const handleRepackClick = (repack: GameRepack) => {
setRepack(repack); setRepack(repack);
@ -89,6 +99,11 @@ export function RepacksModal({
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}> <p style={{ color: "#DADBE1", wordBreak: "break-word" }}>
{repack.title} {repack.title}
</p> </p>
{repack.magnet.toLowerCase().includes(infoHash) && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
<p style={{ fontSize: "12px" }}> <p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repack.repacker} -{" "} {repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate {repack.uploadDate

View File

@ -11,6 +11,7 @@ export const [themeClass, vars] = createTheme({
border: "#424244", border: "#424244",
success: "#1c9749", success: "#1c9749",
danger: "#e11d48", danger: "#e11d48",
warning: "#ffc107",
}, },
opacity: { opacity: {
disabled: "0.5", disabled: "0.5",

View File

@ -106,6 +106,7 @@ export interface Game {
downloader: Downloader; downloader: Downloader;
executablePath: string | null; executablePath: string | null;
lastTimePlayed: Date | null; lastTimePlayed: Date | null;
uri: string | null;
fileSize: number; fileSize: number;
objectID: string; objectID: string;
shop: GameShop; shop: GameShop;

View File

@ -2403,6 +2403,13 @@ crc@^3.8.0:
dependencies: dependencies:
buffer "^5.1.0" buffer "^5.1.0"
create-desktop-shortcuts@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/create-desktop-shortcuts/-/create-desktop-shortcuts-1.11.0.tgz#8eed89329e9bce70dece46d02a80573fe1f2536d"
integrity sha512-nmVtPVqNyMuAyMpDnd7l++hb2laqCWZXnHQaFhqGT1YEi2Ve3unu6QyuyIpGxAwIscNHcG1Ehnl+lFw6ygB2nQ==
dependencies:
which "2.0.2"
cross-fetch-ponyfill@^1.0.3: cross-fetch-ponyfill@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz#5c5524e3bd3374e71d5016c2327e416369a57527" resolved "https://registry.yarnpkg.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz#5c5524e3bd3374e71d5016c2327e416369a57527"
@ -6278,7 +6285,7 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9:
gopd "^1.0.1" gopd "^1.0.1"
has-tostringtag "^1.0.2" has-tostringtag "^1.0.2"
which@^2.0.1: which@2.0.2, which@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==