feat: adding wine prefix

This commit is contained in:
Chubby Granny Chaser 2024-10-19 15:48:41 +01:00
parent a498f9dd80
commit 0e5d37a3a0
No known key found for this signature in database
26 changed files with 423 additions and 130 deletions

View File

@ -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;

View File

@ -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(),
});

View File

@ -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";

View File

@ -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);

View File

@ -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<HydraMigration> {
AddAchievementNotificationPreference,
CreateUserSubscription,
AddBackgroundImageUrl,
AddWinePrefixToGame,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@ -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();
});

View File

@ -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");
});
},
};

View File

@ -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(" "),

View File

@ -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<LudusaviBackup> {
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));
}
}

View File

@ -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;
};

View File

@ -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
);
},

View File

@ -26,7 +26,7 @@ export interface CloudSyncContext {
backupState: CloudSyncState;
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
uploadSaveGame: () => Promise<void>;
uploadSaveGame: (downloadOptionTitle: string | null) => Promise<void>;
deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
getGameBackupPreview: () => Promise<void>;
@ -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(

View File

@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@ -45,6 +46,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
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,

View File

@ -22,6 +22,7 @@ export interface GameDetailsContext {
stats: GameStats | null;
achievements: UserAchievement[] | null;
hasNSFWContentBlocked: boolean;
lastDownloadedOption: GameRepack | null;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;

View File

@ -91,6 +91,7 @@ declare global {
) => Promise<void>;
createGameShortcut: (id: number) => Promise<boolean>;
updateExecutablePath: (id: number, executablePath: string) => Promise<void>;
selectGameWinePrefix: (id: number, winePrefixPath: string) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
@ -125,7 +126,11 @@ declare global {
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
/* Cloud save */
uploadSaveGame: (objectId: string, shop: GameShop) => Promise<void>;
uploadSaveGame: (
objectId: string,
shop: GameShop,
downloadOptionTitle: string | null
) => Promise<void>;
downloadGameArtifact: (
objectId: string,
shop: GameShop,

View File

@ -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",
},
});

View File

@ -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<ModalProps, "children" | "title"> {}
@ -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 (
<Modal
visible={visible}
@ -31,11 +52,11 @@ export function CloudSyncFilesModal({
description="Escolha quais diretórios serão sincronizados"
onClose={onClose}
>
{/* <div className={styles.downloaders}>
{/* <div>
{["AUTOMATIC", "CUSTOM"].map((downloader) => (
<Button
key={downloader}
className={styles.downloaderOption}
// className={styles.downloaderOption}
theme={selectedDownloader === downloader ? "primary" : "outline"}
disabled={
downloader === Downloader.RealDebrid &&
@ -51,6 +72,23 @@ export function CloudSyncFilesModal({
))}
</div> */}
<TextField
// value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_directory_selected")}
rightContent={
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
{t("select_directory")}
</Button>
}
/>
<table>
<thead>
<tr>

View File

@ -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,
},
});

View File

@ -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 (
<Modal
visible={visible}
title={t("cloud_save")}
description={t("cloud_save_description")}
onClose={onClose}
large
>
<div
style={{
marginBottom: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<>
{/* <ConfirmationModal
confirmButtonLabel="confirm"
cancelButtonLabel="cancel"
descriptionText="description"
title="title"
onConfirm={() => {}}
onClose={() => {}}
visible
/> */}
<button
<Modal
visible={visible}
title={t("cloud_save")}
description={t("cloud_save_description")}
onClose={onClose}
large
>
<div
style={{
marginBottom: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<button
type="button"
style={{
margin: 0,
padding: 0,
alignSelf: "flex-start",
fontSize: 14,
cursor: "pointer",
textDecoration: "underline",
color: vars.color.body,
}}
onClick={() => setShowCloudSyncFilesModal(true)}
>
Gerenciar arquivos
</button>
</div>
<Button
type="button"
style={{
margin: 0,
padding: 0,
alignSelf: "flex-start",
fontSize: 14,
cursor: "pointer",
textDecoration: "underline",
color: vars.color.body,
}}
onClick={() => setShowCloudSyncFilesModal(true)}
onClick={() => uploadSaveGame(lastDownloadedOption?.title ?? null)}
disabled={disableActions || !backupPreview}
>
Gerenciar arquivos
</button>
<UploadIcon />
{t("create_backup")}
</Button>
</div>
<Button
type="button"
onClick={uploadSaveGame}
disabled={disableActions || !backupPreview}
>
<UploadIcon />
{t("create_backup")}
</Button>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2>
<small>{artifacts.length} / 2</small>
</div>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2>
<small>{artifacts.length} / 2</small>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
<small>Espaço usado</small>
<progress className={styles.progress} />
</div>
</div>
<ul className={styles.artifacts}>
{artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
<ul className={styles.artifacts}>
{artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<InfoIcon size={14} />
{artifact.downloadOptionTitle ?? t("no_download_option_info")}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
</span>
</div>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
</span>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
>
<HistoryIcon />
{t("install_backup")}
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
theme="danger"
disabled={disableActions}
>
<TrashIcon />
{t("delete_backup")}
</Button>
</div>
</li>
))}
</ul>
</Modal>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
>
<HistoryIcon />
{t("install_backup")}
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
theme="danger"
disabled={disableActions}
>
<TrashIcon />
{t("delete_backup")}
</Button>
</div>
</li>
))}
</ul>
</Modal>
</>
);
}

View File

@ -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 (
<>
<DeleteGameModal
@ -135,6 +150,7 @@ export function GameOptionsModal({
theme="outline"
onClick={handleChangeExecutableLocation}
>
<FileIcon />
{t("select_executable")}
</Button>
}
@ -155,12 +171,41 @@ export function GameOptionsModal({
</div>
)}
{shouldShowWinePrefixConfiguration && (
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("wine_prefix")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("wine_prefix_description")}
</h4>
</div>
<TextField
value={game.winePrefixPath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_directory_selected")}
rightContent={
<Button
type="button"
theme="outline"
onClick={handleChangeWinePrefixPath}
>
<FileDirectoryIcon />
{t("select_executable")}
</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={() => setShowRepacksModal(true)}
@ -179,12 +224,14 @@ export function GameOptionsModal({
</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)}

View File

@ -196,8 +196,10 @@ 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",
},
});

View File

@ -70,7 +70,7 @@ export const heroPanel = style({
export const userInformation = style({
display: "flex",
padding: `${SPACING_UNIT * 6}px ${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 7}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
});

View File

@ -33,7 +33,7 @@ type FriendAction =
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
const backgroundImageLayer =
"linear-gradient(135deg, rgb(0 0 0 / 50%), rgb(0 0 0 / 60%))";
"linear-gradient(135deg, rgb(0 0 0 / 40%), rgb(0 0 0 / 30%))";
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);

View File

@ -12,7 +12,7 @@ export function UploadBackgroundImageButton() {
const { hasActiveSubscription } = useUserDetails();
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
const { patchUser } = useUserDetails();
const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast();
@ -37,6 +37,7 @@ export function UploadBackgroundImageButton() {
await patchUser({ backgroundImageUrl: path });
showSuccessToast("Background image updated");
await fetchUserDetails();
}
} finally {
setIsUploadingBackgorundImage(false);

View File

@ -115,6 +115,7 @@ export interface Game {
bytesDownloaded: number;
playTimeInMilliseconds: number;
downloader: Downloader;
winePrefixPath: string | null;
executablePath: string | null;
lastTimePlayed: Date | null;
uri: string | null;
@ -333,6 +334,7 @@ export type GameAchievementFiles = {
export interface GameArtifact {
id: string;
artifactLengthInBytes: number;
downloadOptionTitle: string | null;
createdAt: string;
updatedAt: string;
hostname: string;

View File

@ -25,3 +25,18 @@ export interface LudusaviBackup {
export interface LudusaviFindResult {
games: Record<string, unknown>;
}
export interface LudusaviConfig {
manifest: {
enable: boolean;
secondary: {
url: string;
enable: boolean;
}[];
};
customGames: {
name: string;
files: string[];
registry: [];
}[];
}