feat: adding onlinefix credentials

This commit is contained in:
Hydra 2024-04-20 17:11:35 +01:00
parent 70fe495965
commit 5bb1f753a2
No known key found for this signature in database
25 changed files with 357 additions and 245 deletions

View File

@ -57,6 +57,8 @@ jobs:
STEAMGRIDDB_API_KEY: ${{ secrets.STEAMGRIDDB_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
- name: Create artifact
uses: actions/upload-artifact@v4

View File

@ -19,7 +19,17 @@ if (process.platform !== "darwin") {
}
if (process.env.SENTRY_DSN) {
init({ dsn: process.env.SENTRY_DSN });
init({
dsn: process.env.SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.telemetryEnabled) return event;
return null;
},
});
}
i18n.init({

View File

@ -115,7 +115,9 @@
"change": "Update",
"notifications": "Notifications",
"enable_download_notifications": "When a download is complete",
"enable_repack_list_notifications": "When a new repack is added"
"enable_repack_list_notifications": "When a new repack is added",
"telemetry": "Telemetry",
"telemetry_description": "Enable anonymous usage statistics"
},
"notifications": {
"download_complete": "Download complete",

View File

@ -111,7 +111,9 @@
"change": "Cambiar",
"notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo"
"enable_repack_list_notifications": "Cuando se añade un repack nuevo",
"telemetry": "Telemetria",
"telemetry_description": "Habilitar estadísticas de uso anónimas"
},
"notifications": {
"download_complete": "Descarga completada",

View File

@ -111,7 +111,9 @@
"change": "Mettre à jour",
"notifications": "Notifications",
"enable_download_notifications": "Quand un téléchargement est terminé",
"enable_repack_list_notifications": "Quand une nouvelle réduction est ajoutée"
"enable_repack_list_notifications": "Quand une nouvelle réduction est ajoutée",
"telemetry": "Télémétrie",
"telemetry_description": "Activer les statistiques d'utilisation anonymes"
},
"notifications": {
"download_complete": "Téléchargement terminé",

View File

@ -111,7 +111,9 @@
"change": "Mudar",
"notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada"
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"telemetry": "Telemetria",
"telemetry_description": "Habilitar estatísticas de uso anônimas"
},
"notifications": {
"download_complete": "Download concluído",

View File

@ -23,6 +23,9 @@ export class UserPreferences {
@Column("boolean", { default: false })
repackUpdatesNotificationsEnabled: boolean;
@Column("boolean", { default: false })
telemetryEnabled: boolean;
@CreateDateColumn()
createdAt: Date;

View File

@ -1,29 +1,39 @@
import type { CatalogueEntry } from "@types";
import type { CatalogueEntry, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
import slice from "lodash/slice";
import { searchRepacks } from "../helpers/search-games";
import { stateManager } from "@main/state-manager";
import { getSteamAppAsset } from "@main/helpers";
const steamGames = stateManager.getValue("steamGames");
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take?: number,
prevCursor = 0
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
let results: CatalogueEntry[] = [];
let i = 0;
const results: CatalogueEntry[] = [];
const batchSize = 100;
let i = 0 + cursor;
while (results.length < take) {
const games = await searchGames({
take: batchSize,
skip: (i + prevCursor) * batchSize,
});
results = [...results, ...games.filter((game) => game.repacks.length)];
const game = steamGames[i];
const repacks = searchRepacks(game.name);
if (repacks.length) {
results.push({
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
repacks,
});
}
i++;
}
return { results: slice(results, 0, take), cursor: prevCursor + i };
return { results, cursor: i };
};
registerEvent(getGames, {

View File

@ -5,14 +5,13 @@ import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { stateManager } from "@main/state-manager";
import { steamGameRepository } from "@main/repository";
import { FindManyOptions, Like } from "typeorm";
import { SteamGame } from "@main/entity";
const { Index } = flexSearch;
const repacksIndex = new Index();
const steamGamesIndex = new Index({ tokenize: "reverse" });
const repacks = stateManager.getValue("repacks");
const steamGames = stateManager.getValue("steamGames");
for (let i = 0; i < repacks.length; i++) {
const repack = repacks[i];
@ -22,9 +21,12 @@ for (let i = 0; i < repacks.length; i++) {
repacksIndex.add(i, formatName(formatter(repack.title)));
}
export const searchRepacks = (title: string): GameRepack[] => {
const repacks = stateManager.getValue("repacks");
for (let i = 0; i < steamGames.length; i++) {
const steamGame = steamGames[i];
steamGamesIndex.add(i, formatName(steamGame.name));
}
export const searchRepacks = (title: string): GameRepack[] => {
return orderBy(
repacksIndex
.search(formatName(title))
@ -45,34 +47,21 @@ export const searchGames = async ({
take,
skip,
}: SearchGamesArgs): Promise<CatalogueEntry[]> => {
const options: FindManyOptions<SteamGame> = {};
const results = steamGamesIndex
.search(formatName(query || ""), { limit: take, offset: skip })
.map((index) => {
const result = steamGames.at(index as number)!;
if (query) {
options.where = {
name: query ? Like(`%${formatName(query)}%`) : undefined,
};
}
return {
objectID: String(result.id),
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)),
repacks: searchRepacks(result.name),
};
});
const steamResults = await steamGameRepository.find({
...options,
take,
skip,
order: { name: "ASC" },
});
const results = steamResults.map((result) => ({
objectID: String(result.id),
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)),
}));
return Promise.all(
results.map(async (result) => ({
...result,
repacks: searchRepacks(result.title),
}))
).then((resultsWithRepacks) =>
return Promise.all(results).then((resultsWithRepacks) =>
orderBy(
resultsWithRepacks,
[({ repacks }) => repacks.length, "repacks"],

View File

@ -11,6 +11,7 @@ const addGameToLibrary = async (
objectID: string,
title: string,
gameShop: GameShop,
executablePath: string
) => {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
@ -19,6 +20,7 @@ const addGameToLibrary = async (
iconUrl,
objectID,
shop: gameShop,
executablePath,
});
};

View File

@ -14,6 +14,7 @@ import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/torrent-client";
@ -104,17 +105,23 @@ const checkForNewRepacks = async () => {
};
const loadState = async () => {
const [friendlyNames, repacks] = await Promise.all([
const [friendlyNames, repacks, steamGames] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
steamGameRepository.find({
order: {
name: "asc",
},
}),
]);
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
stateManager.setValue("steamGames", steamGames);
import("./events");
};

View File

@ -3,7 +3,6 @@ import path from "node:path";
import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager";
@ -18,7 +17,6 @@ export const startProcessWatcher = async () => {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
status: GameStatus.Seeding,
},
});
@ -54,15 +52,16 @@ export const startProcessWatcher = async () => {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
});
gameRepository.update(game.id, {
lastTimePlayed: new Date().toUTCString(),
});
gamesPlaytime.set(game.id, performance.now());
await sleep(sleepTime);
continue;
}
gamesPlaytime.set(game.id, performance.now());
gameRepository.update(game.id, {
lastTimePlayed: new Date().toUTCString(),
});
await sleep(sleepTime);
continue;

View File

@ -42,6 +42,8 @@ const getXatabRepack = async (url: string) => {
if (!$downloadButton) throw new Error("Download button not found");
const torrentBuffer = await getTorrentBuffer($downloadButton.href);
console.log(url);
console.log(torrentBuffer.byteLength);
return {
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),

View File

@ -1,14 +1,16 @@
import type { Repack, RepackerFriendlyName } from "@main/entity";
import type { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
interface State {
repacks: Repack[];
repackersFriendlyNames: RepackerFriendlyName[];
steamGames: SteamGame[];
eventResults: Map<[string, any[]], any>;
}
const initialState: State = {
repacks: [],
repackersFriendlyNames: [],
steamGames: [],
eventResults: new Map(),
};

View File

@ -50,15 +50,26 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateUserPreferences", preferences),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string
) =>
ipcRenderer.invoke(
"addGameToLibrary",
objectID,
title,
shop,
executablePath
),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
getRepackersFriendlyNames: () =>
ipcRenderer.invoke("getRepackersFriendlyNames"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGame: (gameId: number, path: string) =>
ipcRenderer.invoke("openGame", gameId, path),
openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
deleteGameFolder: (gameId: number) =>

View File

@ -203,7 +203,7 @@ export function Sidebar() {
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === null || game.status === "cancelled",
muted: game.status === "cancelled",
})}
>
<button

View File

@ -54,12 +54,13 @@ declare global {
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop
shop: GameShop,
executablePath: string
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
getRepackersFriendlyNames: () => Promise<Record<string, string>>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGame: (gameId: number, path: string) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;

View File

@ -32,7 +32,18 @@ import { store } from "./store";
import * as resources from "@locales";
if (process.env.SENTRY_DSN) {
init({ dsn: process.env.SENTRY_DSN }, reactInit);
init(
{
dsn: process.env.SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await window.electron.getUserPreferences();
if (userPreferences?.telemetryEnabled) return event;
return null;
},
},
reactInit
);
}
const router = createHashRouter([

View File

@ -71,6 +71,7 @@ export function Catalogue() {
display: "flex",
width: "100%",
justifyContent: "space-between",
alignItems: "center",
borderBottom: `1px solid ${vars.color.borderColor}`,
}}
>
@ -103,7 +104,6 @@ export function Catalogue() {
key={game.objectID}
game={game}
onClick={() => handleGameClick(game)}
disabled={!game.repacks.length}
/>
))}
</>

View File

@ -83,7 +83,9 @@ export function GameDetails() {
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setGame(null);
setIsLoading(true);
setIsGamePlaying(false);
dispatch(setHeaderTitle(""));
getRandomGame();

View File

@ -0,0 +1,207 @@
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface HeroPanelActionsProps {
game: Game | null;
gameDetails: ShopDetails | null;
isGamePlaying: boolean;
isGameDownloading: boolean;
openRepacksModal: () => void;
getGame: () => void;
}
export function HeroPanelActions({
game,
gameDetails,
isGamePlaying,
isGameDownloading,
openRepacksModal,
getGame,
}: HeroPanelActionsProps) {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
const {
resumeDownload,
pauseDownload,
cancelDownload,
removeGame,
isGameDeleting,
} = useDownload();
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
const selectGameExecutable = async () => {
return window.electron
.showOpenDialog({
properties: ["openFile"],
filters: [
{ name: "Game executable (.exe)", extensions: ["exe", "app"] },
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
return filePaths[0];
}
});
};
const toggleGameOnLibrary = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game) {
await removeGame(game.id);
} else {
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
gameDetails.objectID,
gameDetails.name,
"steam",
gameExecutablePath
);
}
updateLibrary();
getGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const openGameInstaller = () => {
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
// if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
};
const openGame = async () => {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
if (game?.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
const gameExecutablePath = await selectGameExecutable();
window.electron.openGame(game.id, gameExecutablePath);
};
const closeGame = () => window.electron.closeGame(game.id);
const deleting = isGameDeleting(game?.id);
const toggleGameOnLibraryButton = (
<Button
theme="outline"
disabled={!gameDetails || toggleLibraryGameDisabled}
onClick={toggleGameOnLibrary}
>
{game ? <NoEntryIcon /> : <PlusCircleIcon />}
{game ? t("remove_from_library") : t("add_to_library")}
</Button>
);
if (isGameDownloading) {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button onClick={() => resumeDownload(game.id)} theme="outline">
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(getGame)}
theme="outline"
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "seeding" || (game && !game.status)) {
return (
<>
{game?.status === "seeding" ? (
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("install")}
</Button>
) : (
toggleGameOnLibraryButton
)}
{isGamePlaying ? (
<Button onClick={closeGame} theme="outline" disabled={deleting}>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("play")}
</Button>
)}
</>
);
}
if (game?.status === "cancelled") {
return (
<>
<Button onClick={openRepacksModal} theme="outline" disabled={deleting}>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGame(game.id).then(getGame)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
}
if (gameDetails && gameDetails.repacks.length) {
return (
<>
{toggleGameOnLibraryButton}
<Button onClick={openRepacksModal} theme="outline">
{t("open_download_options")}
</Button>
</>
);
}
return toggleGameOnLibraryButton;
}

View File

@ -2,16 +2,15 @@ import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useDownload } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./hero-panel.css";
import { useDate } from "@renderer/hooks/use-date";
import { formatBytes } from "@renderer/utils";
import { HeroPanelActions } from "./hero-panel-actions";
export interface HeroPanelProps {
game: Game | null;
@ -44,21 +43,8 @@ export function HeroPanel({
eta,
numPeers,
numSeeds,
resumeDownload,
pauseDownload,
cancelDownload,
removeGame,
isGameDeleting,
} = useDownload();
const { updateLibrary, library } = useLibrary();
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
const gameOnLibrary = library.find(
({ objectID }) => objectID === gameDetails?.objectID
);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const updateLastTimePlayed = useCallback(() => {
@ -83,41 +69,6 @@ export function HeroPanel({
}
}, [game?.lastTimePlayed, updateLastTimePlayed]);
const openGameInstaller = () => {
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
};
const openGame = () => {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
if (game?.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
window.electron
.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Game executable (.exe)", extensions: ["exe"] }],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
window.electron.openGame(game.id, path);
}
});
};
const closeGame = () => {
window.electron.closeGame(game.id);
};
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
@ -128,26 +79,6 @@ export function HeroPanel({
return game.repack?.fileSize ?? "N/A";
}, [game, isGameDownloading, gameDownloading]);
const toggleLibraryGame = async () => {
setToggleLibraryGameDisabled(true);
try {
if (gameOnLibrary) {
await window.electron.removeGame(gameOnLibrary.id);
} else {
await window.electron.addGameToLibrary(
gameDetails.objectID,
gameDetails.name,
"steam"
);
}
await updateLibrary();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const getInfo = () => {
if (!gameDetails) return null;
@ -196,7 +127,7 @@ export function HeroPanel({
);
}
if (game?.status === "seeding") {
if (game?.status === "seeding" || (game && !game.status)) {
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
@ -239,112 +170,6 @@ export function HeroPanel({
return <p>{t("no_downloads")}</p>;
};
const getActions = () => {
const deleting = isGameDeleting(game?.id);
const toggleGameOnLibraryButton = (
<Button
theme="outline"
disabled={!gameDetails || toggleLibraryGameDisabled}
onClick={toggleLibraryGame}
>
{gameOnLibrary ? <NoEntryIcon /> : <PlusCircleIcon />}
{gameOnLibrary ? t("remove_from_library") : t("add_to_library")}
</Button>
);
if (isGameDownloading) {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button onClick={() => resumeDownload(game.id)} theme="outline">
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(getGame)}
theme="outline"
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "seeding") {
return (
<>
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("install")}
</Button>
{isGamePlaying ? (
<Button onClick={closeGame} theme="outline" disabled={deleting}>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("play")}
</Button>
)}
</>
);
}
if (game?.status === "cancelled") {
return (
<>
<Button
onClick={openRepacksModal}
theme="outline"
disabled={deleting}
>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGame(game.id).then(getGame)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
}
if (gameDetails && gameDetails.repacks.length) {
return (
<>
{toggleGameOnLibraryButton}
<Button onClick={openRepacksModal} theme="outline">
{t("open_download_options")}
</Button>
</>
);
}
return toggleGameOnLibraryButton;
};
return (
<>
<BinaryNotFoundModal
@ -353,7 +178,16 @@ export function HeroPanel({
/>
<div style={{ backgroundColor: color }} className={styles.panel}>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>{getActions()}</div>
<div className={styles.actions}>
<HeroPanelActions
game={game}
gameDetails={gameDetails}
getGame={getGame}
openRepacksModal={openRepacksModal}
isGamePlaying={isGamePlaying}
isGameDownloading={isGameDownloading}
/>
</div>
</div>
</>
);

View File

@ -67,7 +67,6 @@ export function SearchResults() {
key={game.objectID}
game={game}
onClick={() => handleGameClick(game)}
disabled={!game.repacks.length}
/>
))}
</>

View File

@ -10,6 +10,7 @@ export function Settings() {
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
telemetryEnabled: false,
});
const { t } = useTranslation("settings");
@ -25,6 +26,7 @@ export function Settings() {
userPreferences?.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences?.repackUpdatesNotificationsEnabled,
telemetryEnabled: userPreferences?.telemetryEnabled,
});
});
}, []);
@ -95,6 +97,16 @@ export function Settings() {
)
}
/>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
updateUserPreferences("telemetryEnabled", !form.telemetryEnabled)
}
/>
</div>
</section>
);

View File

@ -104,6 +104,7 @@ export interface UserPreferences {
language: string;
downloadNotificationsEnabled: boolean;
repackUpdatesNotificationsEnabled: boolean;
telemetryEnabled: boolean;
}
export interface HowLongToBeatCategory {