diff --git a/package.json b/package.json index 897ba00c2..2895f20c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.1.4", + "version": "3.1.5", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -47,13 +47,13 @@ "auto-launch": "^5.0.6", "axios": "^1.7.9", "better-sqlite3": "^11.7.0", - "check-disk-space": "^3.4.0", "classnames": "^2.5.1", "color": "^4.2.3", "color.js": "^1.2.0", "create-desktop-shortcuts": "^1.11.0", "date-fns": "^3.6.0", "dexie": "^4.0.10", + "diskusage": "^1.2.0", "electron-log": "^5.2.4", "electron-updater": "^6.3.9", "file-type": "^19.6.0", diff --git a/src/locales/bg/translation.json b/src/locales/bg/translation.json index b46e3fc79..b68e60da4 100644 --- a/src/locales/bg/translation.json +++ b/src/locales/bg/translation.json @@ -58,7 +58,7 @@ }, "game_details": { "launch_options": "Опции за стартиране", - "launch_options_description": "Напредналите потребители могат да въведат модификации на своите опции за стартиране", + "launch_options_description": "Напредналите потребители могат да въведат модификации на своите опции за стартиране (экспериментальный)", "launch_options_placeholder": "Няма зададен параметър", "open_download_options": "Варианти за изтегляне", "download_options_zero": "Няма варианти за изтегляне", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c93cad1a9..d3d68deab 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -168,7 +168,7 @@ "wine_prefix": "Wine Prefix", "wine_prefix_description": "The Wine prefix used to run this game", "launch_options": "Launch Options", - "launch_options_description": "Advanced users may choose to enter modifications to their launch options", + "launch_options_description": "Advanced users may choose to enter modifications to their launch options (experimental feature)", "launch_options_placeholder": "No parameter specified", "no_download_option_info": "No information available", "backup_deletion_failed": "Failed to delete backup", @@ -178,7 +178,8 @@ "select_folder": "Select folder", "backup_from": "Backup from {{date}}", "custom_backup_location_set": "Custom backup location set", - "no_directory_selected": "No directory selected" + "no_directory_selected": "No directory selected", + "no_write_permission": "Cannot download into this directory. Click here to learn more." }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 1c8801765..9c51e68e9 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -156,7 +156,7 @@ "wine_prefix": "Prefixo Wine", "wine_prefix_description": "O prefixo Wine que foi utilizado para instalar o jogo", "launch_options": "Opções de Inicialização", - "launch_options_description": "Usuários avançados podem adicionar opções de inicialização no jogo", + "launch_options_description": "Usuários avançados podem adicionar opções de inicialização no jogo (experimental)", "launch_options_placeholder": "Nenhum parâmetro informado", "no_download_option_info": "Sem informações disponíveis", "backup_deletion_failed": "Falha ao apagar backup", @@ -167,7 +167,8 @@ "select_folder": "Selecione a pasta", "manage_files_description": "Gerencie quais arquivos serão feitos backup", "clear": "Limpar", - "no_directory_selected": "Nenhum diretório selecionado" + "no_directory_selected": "Nenhum diretório selecionado", + "no_write_permission": "O download não pode ser feito neste diretório. Clique aqui para saber mais." }, "activation": { "title": "Ativação", diff --git a/src/main/events/auth/get-session-hash.ts b/src/main/events/auth/get-session-hash.ts index 2a17bcf11..c9dd39cc0 100644 --- a/src/main/events/auth/get-session-hash.ts +++ b/src/main/events/auth/get-session-hash.ts @@ -9,6 +9,8 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { if (!auth) return null; const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload; + if (!payload) return null; + return payload.sessionId; }; diff --git a/src/main/events/cloud-save/get-game-artifacts.ts b/src/main/events/cloud-save/get-game-artifacts.ts index dbdcb8536..3fa8552c7 100644 --- a/src/main/events/cloud-save/get-game-artifacts.ts +++ b/src/main/events/cloud-save/get-game-artifacts.ts @@ -1,7 +1,7 @@ import { HydraApi } from "@main/services"; import { registerEvent } from "../register-event"; import type { GameArtifact, GameShop } from "@types"; -import { SubscriptionRequiredError } from "@shared"; +import { SubscriptionRequiredError, UserNotLoggedInError } from "@shared"; const getGameArtifacts = async ( _event: Electron.IpcMainInvokeEvent, @@ -22,6 +22,10 @@ const getGameArtifacts = async ( return []; } + if (err instanceof UserNotLoggedInError) { + return []; + } + throw err; }); }; diff --git a/src/main/events/hardware/check-folder-write-permission.ts b/src/main/events/hardware/check-folder-write-permission.ts new file mode 100644 index 000000000..c74f01e70 --- /dev/null +++ b/src/main/events/hardware/check-folder-write-permission.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; + +import { registerEvent } from "../register-event"; + +const checkFolderWritePermission = async ( + _event: Electron.IpcMainInvokeEvent, + path: string +) => + new Promise((resolve) => { + fs.access(path, fs.constants.W_OK, (err) => { + resolve(!err); + }); + }); + +registerEvent("checkFolderWritePermission", checkFolderWritePermission); diff --git a/src/main/events/hardware/get-disk-free-space.ts b/src/main/events/hardware/get-disk-free-space.ts index ca5918651..b5ac86e36 100644 --- a/src/main/events/hardware/get-disk-free-space.ts +++ b/src/main/events/hardware/get-disk-free-space.ts @@ -1,10 +1,10 @@ -import checkDiskSpace from "check-disk-space"; +import disk from "diskusage"; import { registerEvent } from "../register-event"; const getDiskFreeSpace = async ( _event: Electron.IpcMainInvokeEvent, path: string -) => checkDiskSpace(path); +) => disk.check(path); registerEvent("getDiskFreeSpace", getDiskFreeSpace); diff --git a/src/main/events/helpers/parse-launch-options.ts b/src/main/events/helpers/parse-launch-options.ts deleted file mode 100644 index e1b562590..000000000 --- a/src/main/events/helpers/parse-launch-options.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const parseLaunchOptions = (params: string | null): string[] => { - if (params == null || params == "") { - return []; - } - - const paramsSplit = params.split(" "); - - return paramsSplit; -}; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index d40539746..689440609 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -11,6 +11,7 @@ import "./catalogue/get-trending-games"; import "./catalogue/get-publishers"; import "./catalogue/get-developers"; import "./hardware/get-disk-free-space"; +import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; import "./library/create-game-shortcut"; import "./library/close-game"; @@ -30,6 +31,8 @@ import "./library/select-game-wine-prefix"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; +import "./misc/get-features"; +import "./misc/show-item-in-folder"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; @@ -71,7 +74,6 @@ import "./cloud-save/delete-game-artifact"; import "./cloud-save/select-game-backup-path"; import "./notifications/publish-new-repacks-notification"; import { isPortableVersion } from "@main/helpers"; -import "./misc/show-item-in-folder"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => appVersion); diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index f43dd1a9d..cf73c8109 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -2,9 +2,7 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { shell } from "electron"; -import { spawn } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; -import { parseLaunchOptions } from "../helpers/parse-launch-options"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -12,21 +10,15 @@ const openGame = async ( executablePath: string, launchOptions: string | null ) => { + // TODO: revisit this for launchOptions const parsedPath = parseExecutablePath(executablePath); - const parsedParams = parseLaunchOptions(launchOptions); await gameRepository.update( { id: gameId }, { executablePath: parsedPath, launchOptions } ); - if (process.platform === "linux" || process.platform === "darwin") { - shell.openPath(parsedPath); - } - - if (process.platform === "win32") { - spawn(parsedPath, parsedParams, { shell: false, detached: true }); - } + shell.openPath(parsedPath); }; registerEvent("openGame", openGame); diff --git a/src/main/events/misc/get-features.ts b/src/main/events/misc/get-features.ts new file mode 100644 index 000000000..766c84aa2 --- /dev/null +++ b/src/main/events/misc/get-features.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => { + return HydraApi.get("/features", null, { needsAuth: false }); +}; + +registerEvent("getFeatures", getFeatures); diff --git a/src/main/events/misc/open-checkout.ts b/src/main/events/misc/open-checkout.ts index 5d087be00..ba48f03b8 100644 --- a/src/main/events/misc/open-checkout.ts +++ b/src/main/events/misc/open-checkout.ts @@ -1,16 +1,10 @@ import { shell } from "electron"; import { registerEvent } from "../register-event"; -import { - userAuthRepository, - userPreferencesRepository, -} from "@main/repository"; +import { userAuthRepository } from "@main/repository"; import { HydraApi } from "@main/services"; const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { - const [userAuth, userPreferences] = await Promise.all([ - userAuthRepository.findOne({ where: { id: 1 } }), - userPreferencesRepository.findOne({ where: { id: 1 } }), - ]); + const userAuth = await userAuthRepository.findOne({ where: { id: 1 } }); if (!userAuth) { return; @@ -22,7 +16,6 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { const params = new URLSearchParams({ token: paymentToken, - lng: userPreferences?.language || "en", }); shell.openExternal( diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index adc2f3015..a7cfcee2a 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -64,7 +64,10 @@ export class WindowManager { this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { - if (details.webContentsId !== this.mainWindow?.webContents.id) { + if ( + details.webContentsId !== this.mainWindow?.webContents.id || + details.url.includes("chatwoot") + ) { return callback(details); } @@ -81,15 +84,11 @@ export class WindowManager { this.mainWindow.webContents.session.webRequest.onHeadersReceived( (details, callback) => { - if (details.webContentsId !== this.mainWindow?.webContents.id) { - return callback(details); - } - - if (details.url.includes("featurebase")) { - return callback(details); - } - - if (details.url.includes("chatwoot")) { + if ( + details.webContentsId !== this.mainWindow?.webContents.id || + details.url.includes("featurebase") || + details.url.includes("chatwoot") + ) { return callback(details); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a8ed69e4..7b5550003 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -150,6 +150,8 @@ contextBridge.exposeInMainWorld("electron", { /* Hardware */ getDiskFreeSpace: (path: string) => ipcRenderer.invoke("getDiskFreeSpace", path), + checkFolderWritePermission: (path: string) => + ipcRenderer.invoke("checkFolderWritePermission", path), /* Cloud save */ uploadSaveGame: ( @@ -226,6 +228,7 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("showOpenDialog", options), showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), + getFeatures: () => ipcRenderer.invoke("getFeatures"), platform: process.platform, /* Auto update */ diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index af15feb52..d8d0554d6 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -46,6 +46,12 @@ export function Modal({ }, [onClose]); const isTopMostModal = () => { + if ( + document.querySelector( + ".featurebase-widget-overlay.featurebase-display-block" + ) + ) + return false; const openModals = document.querySelectorAll("[role=dialog]"); return ( diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index d4dfa0078..32664e032 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -1,6 +1,5 @@ import React, { useId, useMemo, useState } from "react"; import type { RecipeVariants } from "@vanilla-extract/recipes"; -import type { FieldError, FieldErrorsImpl, Merge } from "react-hook-form"; import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; @@ -23,7 +22,7 @@ export interface TextFieldProps HTMLDivElement >; rightContent?: React.ReactNode | null; - error?: FieldError | Merge> | undefined; + error?: string | React.ReactNode; } export const TextField = React.forwardRef( @@ -55,10 +54,7 @@ export const TextField = React.forwardRef( }, [props.type, isPasswordVisible]); const hintContent = useMemo(() => { - if (error && error.message) - return ( - {error.message as string} - ); + if (error) return {error}; if (hint) return {hint}; return null; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 09ac7257d..9242d9a6a 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -117,11 +117,7 @@ export function GameDetailsContextProvider({ abortControllerRef.current = abortController; window.electron - .getGameShopDetails( - objectId!, - shop as GameShop, - getSteamLanguage(i18n.language) - ) + .getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language)) .then((result) => { if (abortController.signal.aborted) return; @@ -140,14 +136,14 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - window.electron.getGameStats(objectId, shop as GameShop).then((result) => { + window.electron.getGameStats(objectId, shop).then((result) => { if (abortController.signal.aborted) return; setStats(result); }); if (userDetails) { window.electron - .getUnlockedAchievements(objectId, shop as GameShop) + .getUnlockedAchievements(objectId, shop) .then((achievements) => { if (abortController.signal.aborted) return; setAchievements(achievements); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index feec8284e..88a16665f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -31,7 +31,7 @@ import type { CatalogueSearchPayload, } from "@types"; import type { AxiosProgressEvent } from "axios"; -import type { DiskSpace } from "check-disk-space"; +import type disk from "diskusage"; declare global { declare module "*.svg" { @@ -140,7 +140,8 @@ declare global { ) => Promise<{ fingerprint: string }>; /* Hardware */ - getDiskFreeSpace: (path: string) => Promise; + getDiskFreeSpace: (path: string) => Promise; + checkFolderWritePermission: (path: string) => Promise; /* Cloud save */ uploadSaveGame: ( @@ -195,6 +196,7 @@ declare global { options: Electron.OpenDialogOptions ) => Promise; showItemInFolder: (path: string) => Promise; + getFeatures: () => Promise; platform: NodeJS.Platform; /* Auto update */ diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 97f519efd..8140e0cda 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-repacks"; +export * from "./use-feature"; diff --git a/src/renderer/src/hooks/use-feature.ts b/src/renderer/src/hooks/use-feature.ts new file mode 100644 index 000000000..ea682ce41 --- /dev/null +++ b/src/renderer/src/hooks/use-feature.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +enum Feature { + CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION", +} + +export function useFeature() { + useEffect(() => { + window.electron.getFeatures().then((features) => { + localStorage.setItem("features", JSON.stringify(features || [])); + }); + }, []); + + const isFeatureEnabled = (feature: Feature) => { + const features = JSON.parse(localStorage.getItem("features") || "[]"); + return features.includes(feature); + }; + + return { + isFeatureEnabled, + Feature, + }; +} diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 3328c517b..0679cde84 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -13,6 +13,7 @@ import type { UpdateProfileRequest, UserDetails, } from "@types"; +import * as Sentry from "@sentry/react"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { isFuture, isToday } from "date-fns"; @@ -30,6 +31,8 @@ export function useUserDetails() { } = useAppSelector((state) => state.userDetails); const clearUserDetails = useCallback(async () => { + Sentry.setUser(null); + dispatch(setUserDetails(null)); dispatch(setProfileBackground(null)); @@ -44,6 +47,12 @@ export function useUserDetails() { const updateUserDetails = useCallback( async (userDetails: UserDetails) => { + Sentry.setUser({ + id: userDetails.id, + username: userDetails.username, + email: userDetails.email ?? undefined, + }); + dispatch(setUserDetails(userDetails)); window.localStorage.setItem("userDetails", JSON.stringify(userDetails)); }, diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index 8fb4c1b63..184548706 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -31,11 +31,11 @@ export function GameItem({ game }: GameItemProps) { const genres = useMemo(() => { return game.genres?.map((genre) => { - const index = steamGenres["en"].findIndex( + const index = steamGenres["en"]?.findIndex( (steamGenre) => steamGenre === genre ); - if (steamGenres[language] && steamGenres[language][index]) { + if (index && steamGenres[language] && steamGenres[language][index]) { return steamGenres[language][index]; } diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts index 5450378c9..3a7767364 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.css.ts @@ -36,3 +36,10 @@ export const downloaderIcon = style({ position: "absolute", left: `${SPACING_UNIT * 2}px`, }); + +export const pathError = style({ + cursor: "pointer", + ":hover": { + textDecoration: "underline", + }, +}); diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 191d9ac16..541bd01cd 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { DiskSpace } from "check-disk-space"; import * as styles from "./download-settings-modal.css"; import { Button, Link, Modal, TextField } from "@renderer/components"; import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; @@ -10,7 +9,7 @@ import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import type { GameRepack } from "@types"; import { SPACING_UNIT } from "@renderer/theme.css"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector, useToast } from "@renderer/hooks"; +import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; export interface DownloadSettingsModalProps { visible: boolean; @@ -33,21 +32,45 @@ export function DownloadSettingsModal({ const { showErrorToast } = useToast(); - const [diskFreeSpace, setDiskFreeSpace] = useState(null); + const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); const [selectedDownloader, setSelectedDownloader] = useState(null); + const [hasWritePermission, setHasWritePermission] = useState( + null + ); + + const { isFeatureEnabled, Feature } = useFeature(); const userPreferences = useAppSelector( (state) => state.userPreferences.value ); + const getDiskFreeSpace = (path: string) => { + window.electron.getDiskFreeSpace(path).then((result) => { + setDiskFreeSpace(result.free); + }); + }; + + const checkFolderWritePermission = useCallback( + async (path: string) => { + if (isFeatureEnabled(Feature.CheckDownloadWritePermission)) { + const result = await window.electron.checkFolderWritePermission(path); + setHasWritePermission(result); + } else { + setHasWritePermission(true); + } + }, + [Feature, isFeatureEnabled] + ); + useEffect(() => { if (visible) { getDiskFreeSpace(selectedPath); + checkFolderWritePermission(selectedPath); } - }, [visible, selectedPath]); + }, [visible, checkFolderWritePermission, selectedPath]); const downloaders = useMemo(() => { return getDownloadersForUris(repack?.uris ?? []); @@ -84,12 +107,6 @@ export function DownloadSettingsModal({ userPreferences?.realDebridApiToken, ]); - const getDiskFreeSpace = (path: string) => { - window.electron.getDiskFreeSpace(path).then((result) => { - setDiskFreeSpace(result); - }); - }; - const handleChooseDownloadsPath = async () => { const { filePaths } = await window.electron.showOpenDialog({ defaultPath: selectedPath, @@ -124,7 +141,7 @@ export function DownloadSettingsModal({ visible={visible} title={t("download_settings")} description={t("space_left_on_disk", { - space: formatBytes(diskFreeSpace?.free ?? 0), + space: formatBytes(diskFreeSpace ?? 0), })} onClose={onClose} > @@ -168,23 +185,32 @@ export function DownloadSettingsModal({ gap: `${SPACING_UNIT}px`, }} > -
- - - -
+ + {t("no_write_permission")} + + ) : undefined + } + rightContent={ + + } + />

@@ -195,7 +221,11 @@ export function DownloadSettingsModal({