diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 190e7470..19905c32 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -39,6 +39,9 @@ export class Game { @Column("text", { nullable: true }) executablePath: string | null; + @Column("text", { nullable: true }) + winePrefixPath: string | null; + @Column("int", { default: 0 }) playTimeInMilliseconds: number; diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index b66fd081..6cf68596 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -10,8 +10,13 @@ import os from "node:os"; import { backupsPath } from "@main/constants"; import { app } from "electron"; import { normalizePath } from "@main/helpers"; +import { gameRepository } from "@main/repository"; -const bundleBackup = async (shop: GameShop, objectId: string) => { +const bundleBackup = async ( + shop: GameShop, + objectId: string, + winePrefix: string | null +) => { const backupPath = path.join(backupsPath, `${shop}-${objectId}`); // Remove existing backup @@ -19,7 +24,7 @@ const bundleBackup = async (shop: GameShop, objectId: string) => { fs.rmSync(backupPath, { recursive: true }); } - await Ludusavi.backupGame(shop, objectId, backupPath); + await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix); const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`); @@ -38,9 +43,21 @@ const bundleBackup = async (shop: GameShop, objectId: string) => { const uploadSaveGame = async ( _event: Electron.IpcMainInvokeEvent, objectId: string, - shop: GameShop + shop: GameShop, + downloadOptionTitle: string | null ) => { - const bundleLocation = await bundleBackup(shop, objectId); + const game = await gameRepository.findOne({ + where: { + objectID: objectId, + shop, + }, + }); + + const bundleLocation = await bundleBackup( + shop, + objectId, + game?.winePrefixPath ?? null + ); fs.stat(bundleLocation, async (err, stat) => { if (err) { @@ -57,6 +74,7 @@ const uploadSaveGame = async ( objectId, hostname: os.hostname(), homeDir: normalizePath(app.getPath("home")), + downloadOptionTitle, platform: os.platform(), }); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 1b621a30..80363e4e 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -25,6 +25,7 @@ import "./library/update-executable-path"; import "./library/verify-executable-path"; import "./library/remove-game"; import "./library/remove-game-from-library"; +import "./library/select-game-wine-prefix"; import "./misc/open-external"; import "./misc/show-open-dialog"; import "./torrenting/cancel-game-download"; diff --git a/src/main/events/library/select-game-wine-prefix.ts b/src/main/events/library/select-game-wine-prefix.ts new file mode 100644 index 00000000..a75a3cb0 --- /dev/null +++ b/src/main/events/library/select-game-wine-prefix.ts @@ -0,0 +1,13 @@ +import { gameRepository } from "@main/repository"; + +import { registerEvent } from "../register-event"; + +const selectGameWinePrefix = async ( + _event: Electron.IpcMainInvokeEvent, + id: number, + winePrefixPath: string +) => { + return gameRepository.update({ id }, { winePrefixPath }); +}; + +registerEvent("selectGameWinePrefix", selectGameWinePrefix); diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts index 48561670..5f81ffbc 100644 --- a/src/main/knex-client.ts +++ b/src/main/knex-client.ts @@ -10,6 +10,7 @@ import { CreateGameAchievement } from "./migrations/20240919030940_create_game_a import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference"; import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription"; import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url"; +import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game"; export type HydraMigration = Knex.Migration & { name: string }; @@ -25,6 +26,7 @@ class MigrationSource implements Knex.MigrationSource { AddAchievementNotificationPreference, CreateUserSubscription, AddBackgroundImageUrl, + AddWinePrefixToGame, ]); } getMigrationName(migration: HydraMigration): string { diff --git a/src/main/main.ts b/src/main/main.ts index 7f3d6370..69bc62e0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,9 @@ -import { DownloadManager, PythonInstance, startMainLoop } from "./services"; +import { + DownloadManager, + Ludusavi, + PythonInstance, + startMainLoop, +} from "./services"; import { downloadQueueRepository, userPreferencesRepository, @@ -15,6 +20,8 @@ const loadState = async (userPreferences: UserPreferences | null) => { RealDebridClient.authorize(userPreferences?.realDebridApiToken); } + Ludusavi.addManifestToLudusaviConfig(); + HydraApi.setupApi().then(() => { uploadGamesBatch(); }); diff --git a/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts b/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts new file mode 100644 index 00000000..517f6fb5 --- /dev/null +++ b/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts @@ -0,0 +1,17 @@ +import type { HydraMigration } from "@main/knex-client"; +import type { Knex } from "knex"; + +export const AddWinePrefixToGame: HydraMigration = { + name: "AddWinePrefixToGame", + up: (knex: Knex) => { + return knex.schema.alterTable("game", (table) => { + return table.text("winePrefixPath").nullable(); + }); + }, + + down: async (knex: Knex) => { + return knex.schema.alterTable("game", (table) => { + return table.dropColumn("winePrefixPath"); + }); + }, +}; diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index 5a82d8e7..1e5f3279 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -50,7 +50,7 @@ export const searchHowLongToBeat = async (gameName: string) => { const response = await axios .post( - "https://howlongtobeat.com/api/search/8fbd64723a8204dd", + `https://howlongtobeat.com/api/search/${state.apiKey}`, { searchType: "games", searchTerms: formatName(gameName).split(" "), diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts index 838b5f9b..91633e36 100644 --- a/src/main/services/ludusavi.ts +++ b/src/main/services/ludusavi.ts @@ -1,20 +1,27 @@ -import { GameShop, LudusaviBackup } from "@types"; +import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types"; import Piscina from "piscina"; import { app } from "electron"; +import fs from "node:fs"; import path from "node:path"; +import YAML from "yaml"; import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath"; -const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "ludusavi", "ludusavi") - : path.join(__dirname, "..", "..", "ludusavi", "ludusavi"); - export class Ludusavi { + private static ludusaviPath = path.join(app.getPath("appData"), "ludusavi"); + private static ludusaviConfigPath = path.join( + this.ludusaviPath, + "config.yaml" + ); + private static binaryPath = app.isPackaged + ? path.join(process.resourcesPath, "ludusavi", "ludusavi") + : path.join(__dirname, "..", "..", "ludusavi", "ludusavi"); + private static worker = new Piscina({ filename: ludusaviWorkerPath, workerData: { - binaryPath, + binaryPath: this.binaryPath, }, }); @@ -27,16 +34,29 @@ export class Ludusavi { return games; } + static async getConfig() { + if (!fs.existsSync(this.ludusaviConfigPath)) { + await this.worker.run(undefined, { name: "generateConfig" }); + } + + const config = YAML.parse( + fs.readFileSync(this.ludusaviConfigPath, "utf-8") + ) as LudusaviConfig; + + return config; + } + static async backupGame( shop: GameShop, objectId: string, - backupPath: string + backupPath: string, + winePrefix?: string | null ): Promise { const games = await this.findGames(shop, objectId); if (!games.length) throw new Error("Game not found"); return this.worker.run( - { title: games[0], backupPath }, + { title: games[0], backupPath, winePrefix }, { name: "backupGame" } ); } @@ -60,4 +80,31 @@ export class Ludusavi { static async restoreBackup(backupPath: string) { return this.worker.run(backupPath, { name: "restoreBackup" }); } + + static async addManifestToLudusaviConfig() { + const config = await this.getConfig(); + + config.manifest.enable = false; + config.manifest.secondary = [ + { url: "https://cdn.losbroxas.org/manifest.yaml", enable: true }, + ]; + + fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); + } + + static async addCustomGame(title: string, savePath: string) { + const config = await this.getConfig(); + const filteredGames = config.customGames.filter( + (game) => game.name !== title + ); + + filteredGames.push({ + name: title, + files: [savePath], + registry: [], + }); + + config.customGames = filteredGames; + fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); + } } diff --git a/src/main/workers/ludusavi.worker.ts b/src/main/workers/ludusavi.worker.ts index e6ccdaad..469ab721 100644 --- a/src/main/workers/ludusavi.worker.ts +++ b/src/main/workers/ludusavi.worker.ts @@ -58,4 +58,8 @@ export const restoreBackup = (backupPath: string) => { return JSON.parse(result.toString("utf-8")) as LudusaviBackup; }; -// --wine-prefix +export const generateConfig = () => { + const result = cp.execFileSync(binaryPath, ["schema", "config"]); + + return JSON.parse(result.toString("utf-8")) as LudusaviBackup; +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index 11086199..fc2e5a91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -107,6 +107,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("createGameShortcut", id), updateExecutablePath: (id: number, executablePath: string) => ipcRenderer.invoke("updateExecutablePath", id, executablePath), + selectGameWinePrefix: (id: number, winePrefixPath: string) => + ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath), verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), @@ -148,8 +150,12 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getDiskFreeSpace", path), /* Cloud save */ - uploadSaveGame: (objectId: string, shop: GameShop) => - ipcRenderer.invoke("uploadSaveGame", objectId, shop), + uploadSaveGame: ( + objectId: string, + shop: GameShop, + downloadOptionTitle: string | null + ) => + ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle), downloadGameArtifact: ( objectId: string, shop: GameShop, @@ -183,7 +189,7 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on(`on-backup-download-progress-${objectId}-${shop}`, listener); return () => ipcRenderer.removeListener( - `on-backup-download-complete-${objectId}-${shop}`, + `on-backup-download-progress-${objectId}-${shop}`, listener ); }, diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index 5a0a66f0..7b102918 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -26,7 +26,7 @@ export interface CloudSyncContext { backupState: CloudSyncState; setShowCloudSyncModal: React.Dispatch>; downloadGameArtifact: (gameArtifactId: string) => Promise; - uploadSaveGame: () => Promise; + uploadSaveGame: (downloadOptionTitle: string | null) => Promise; deleteGameArtifact: (gameArtifactId: string) => Promise; setShowCloudSyncFilesModal: React.Dispatch>; getGameBackupPreview: () => Promise; @@ -108,10 +108,13 @@ export function CloudSyncContextProvider({ ]); }, [objectId, shop]); - const uploadSaveGame = useCallback(async () => { - setUploadingBackup(true); - window.electron.uploadSaveGame(objectId, shop); - }, [objectId, shop]); + const uploadSaveGame = useCallback( + async (downloadOptionTitle: string | null) => { + setUploadingBackup(true); + window.electron.uploadSaveGame(objectId, shop, downloadOptionTitle); + }, + [objectId, shop] + ); useEffect(() => { const removeUploadCompleteListener = window.electron.onUploadComplete( 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 6aaf112a..c308ab2f 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from "react"; @@ -45,6 +46,7 @@ export const gameDetailsContext = createContext({ stats: null, achievements: null, hasNSFWContentBlocked: false, + lastDownloadedOption: null, setGameColor: () => {}, selectGameExecutable: async () => null, updateGame: async () => {}, @@ -199,6 +201,19 @@ export function GameDetailsContextProvider({ }; }, [game?.id, isGameRunning, updateGame]); + const lastDownloadedOption = useMemo(() => { + if (game?.uri) { + const repack = repacks.find((repack) => + repack.uris.some((uri) => uri.includes(game.uri!)) + ); + + if (!repack) return null; + return repack; + } + + return null; + }, [game?.uri, repacks]); + useEffect(() => { const unsubscribe = window.electron.onUpdateAchievements( objectId, @@ -259,6 +274,7 @@ export function GameDetailsContextProvider({ stats, achievements, hasNSFWContentBlocked, + lastDownloadedOption, setHasNSFWContentBlocked, setGameColor, selectGameExecutable, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index ad5c4de7..49718430 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -22,6 +22,7 @@ export interface GameDetailsContext { stats: GameStats | null; achievements: UserAchievement[] | null; hasNSFWContentBlocked: boolean; + lastDownloadedOption: GameRepack | null; setGameColor: React.Dispatch>; selectGameExecutable: () => Promise; updateGame: () => Promise; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 75ed137a..91bed316 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -91,6 +91,7 @@ declare global { ) => Promise; createGameShortcut: (id: number) => Promise; updateExecutablePath: (id: number, executablePath: string) => Promise; + selectGameWinePrefix: (id: number, winePrefixPath: string) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; openGameInstaller: (gameId: number) => Promise; @@ -125,7 +126,11 @@ declare global { getDiskFreeSpace: (path: string) => Promise; /* Cloud save */ - uploadSaveGame: (objectId: string, shop: GameShop) => Promise; + uploadSaveGame: ( + objectId: string, + shop: GameShop, + downloadOptionTitle: string | null + ) => Promise; downloadGameArtifact: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/pages/achievements/achievements.css.ts b/src/renderer/src/pages/achievements/achievements.css.ts index 682fd2e5..24f1d507 100644 --- a/src/renderer/src/pages/achievements/achievements.css.ts +++ b/src/renderer/src/pages/achievements/achievements.css.ts @@ -134,9 +134,11 @@ export const achievementsProgressBar = style({ transition: "all ease 0.2s", "::-webkit-progress-bar": { backgroundColor: "rgba(255, 255, 255, 0.15)", + borderRadius: "4px", }, "::-webkit-progress-value": { backgroundColor: vars.color.muted, + borderRadius: "4px", }, }); diff --git a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx index 9c57c6d2..900e96c5 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx @@ -1,6 +1,7 @@ -import { Modal, ModalProps } from "@renderer/components"; +import { Button, Modal, ModalProps, TextField } from "@renderer/components"; import { useContext, useMemo } from "react"; import { cloudSyncContext } from "@renderer/context"; +import { useTranslation } from "react-i18next"; export interface CloudSyncFilesModalProps extends Omit {} @@ -11,6 +12,8 @@ export function CloudSyncFilesModal({ }: CloudSyncFilesModalProps) { const { backupPreview } = useContext(cloudSyncContext); + const { t } = useTranslation("game_details"); + const files = useMemo(() => { if (!backupPreview) { return []; @@ -24,6 +27,24 @@ export function CloudSyncFilesModal({ }); }, [backupPreview]); + const handleChangeExecutableLocation = async () => { + const path = await selectGameExecutable(); + + if (path) { + const gameUsingPath = + await window.electron.verifyExecutablePathInUse(path); + + if (gameUsingPath) { + showErrorToast( + t("executable_path_in_use", { game: gameUsingPath.title }) + ); + return; + } + + window.electron.updateExecutablePath(game.id, path).then(updateGame); + } + }; + return ( - {/*
+ {/*
{["AUTOMATIC", "CUSTOM"].map((downloader) => ( + } + /> + diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts index 916b7a1f..77231403 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts @@ -38,3 +38,14 @@ export const syncIcon = style({ animationIterationCount: "infinite", animationTimingFunction: "linear", }); + +export const progress = style({ + width: "100%", + height: "5px", + "::-webkit-progress-bar": { + backgroundColor: vars.color.darkBackground, + }, + "::-webkit-progress-value": { + backgroundColor: vars.color.muted, + }, +}); diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx index 27ac7d80..f636d9d2 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -1,4 +1,9 @@ -import { Button, Modal, ModalProps } from "@renderer/components"; +import { + Button, + ConfirmationModal, + Modal, + ModalProps, +} from "@renderer/components"; import { useContext, useEffect, useMemo, useState } from "react"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; @@ -10,6 +15,7 @@ import { ClockIcon, DeviceDesktopIcon, HistoryIcon, + InfoIcon, SyncIcon, TrashIcon, UploadIcon, @@ -43,7 +49,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { setShowCloudSyncFilesModal, } = useContext(cloudSyncContext); - const { objectId, shop, gameTitle } = useContext(gameDetailsContext); + const { objectId, shop, gameTitle, lastDownloadedOption } = + useContext(gameDetailsContext); const { showSuccessToast, showErrorToast } = useToast(); @@ -140,112 +147,137 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { const disableActions = uploadingBackup || restoringBackup || deletingArtifact; return ( - -
-
-

{gameTitle}

-

{backupStateLabel}

+ <> + {/* {}} + onClose={() => {}} + visible + /> */} - +
+ + + + {t("create_backup")} +
- - +
+
+

{t("backups")}

+ {artifacts.length} / 2 +
-
-

{t("backups")}

- {artifacts.length} / 2 -
+
+ Espaço usado + +
+
-
    - {artifacts.map((artifact) => ( -
  • -
    -
    -

    Backup do dia {format(artifact.createdAt, "dd/MM")}

    - {formatBytes(artifact.artifactLengthInBytes)} +
      + {artifacts.map((artifact) => ( +
    • +
      +
      +

      Backup do dia {format(artifact.createdAt, "dd/MM")}

      + {formatBytes(artifact.artifactLengthInBytes)} +
      + + + + {artifact.hostname} + + + + + {artifact.downloadOptionTitle ?? t("no_download_option_info")} + + + + + {format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")} +
      - - - {artifact.hostname} - - - - - {format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")} - -
    - -
    - - -
    -
  • - ))} -
-
+
+ + +
+ + ))} + + + ); } diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index d1253318..afbcfd92 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -7,6 +7,7 @@ import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast } from "@renderer/hooks"; import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; +import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; export interface GameOptionsModalProps { visible: boolean; @@ -94,6 +95,20 @@ export function GameOptionsModal({ await window.electron.openGameExecutablePath(game.id); }; + const handleChangeWinePrefixPath = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openDirectory"], + }); + + if (filePaths && filePaths.length > 0) { + await window.electron.selectGameWinePrefix(game.id, filePaths[0]); + await updateGame(); + } + }; + + const shouldShowWinePrefixConfiguration = + window.electron.platform === "darwin"; + return ( <> + {t("select_executable")} } @@ -155,12 +171,41 @@ export function GameOptionsModal({ )} + {shouldShowWinePrefixConfiguration && ( +
+
+

{t("wine_prefix")}

+

+ {t("wine_prefix_description")} +

+
+ + + {t("select_executable")} + + } + /> +
+ )} +

{t("downloads_secion_title")}

{t("downloads_section_description")}

+
)}
+

{t("danger_zone_section_title")}

{t("danger_zone_section_description")}

+