diff --git a/electron-builder.yml b/electron-builder.yml index a085b1e9..06473566 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,4 +1,4 @@ -appId: site.hydralauncher.hydra +appId: gg.hydralauncher.hydra productName: Hydra directories: buildResources: build diff --git a/package.json b/package.json index a9fd7786..6fd3f905 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "color.js": "^1.2.0", "create-desktop-shortcuts": "^1.11.0", "date-fns": "^3.6.0", + "dexie": "^4.0.8", "electron-log": "^5.1.4", "electron-updater": "^6.1.8", "fetch-cookie": "^3.0.1", diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index 8d6183a5..4fdb95bd 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -1,8 +1,8 @@ import type { GameShop } from "@types"; import { registerEvent } from "../register-event"; -import { HydraApi, RepacksManager } from "@main/services"; -import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared"; +import { HydraApi } from "@main/services"; +import { CatalogueCategory, steamUrlBuilder } from "@shared"; import { steamGamesWorker } from "@main/workers"; const getCatalogue = async ( @@ -26,14 +26,9 @@ const getCatalogue = async ( name: "getById", }); - const repacks = RepacksManager.search({ - query: formatName(steamGame.name), - }); - return { title: steamGame.name, shop: game.shop, - repacks, cover: steamUrlBuilder.library(game.objectId), objectID: game.objectId, }; diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index 0b4535f6..3a435013 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -45,15 +45,17 @@ const getGameShopDetails = async ( const appDetails = getLocalizedSteamAppDetails(objectID, language).then( (result) => { - gameShopCacheRepository.upsert( - { - objectID, - shop: "steam", - language, - serializedData: JSON.stringify(result), - }, - ["objectID"] - ); + if (result) { + gameShopCacheRepository.upsert( + { + objectID, + shop: "steam", + language, + serializedData: JSON.stringify(result), + }, + ["objectID"] + ); + } return result; } diff --git a/src/main/events/catalogue/get-games.ts b/src/main/events/catalogue/get-games.ts index c34451eb..81717806 100644 --- a/src/main/events/catalogue/get-games.ts +++ b/src/main/events/catalogue/get-games.ts @@ -2,8 +2,7 @@ import type { CatalogueEntry } from "@types"; import { registerEvent } from "../register-event"; import { steamGamesWorker } from "@main/workers"; -import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; -import { RepacksManager } from "@main/services"; +import { steamUrlBuilder } from "@shared"; const getGames = async ( _event: Electron.IpcMainInvokeEvent, @@ -15,13 +14,14 @@ const getGames = async ( { name: "list" } ); - const entries = RepacksManager.findRepacksForCatalogueEntries( - steamGames.map((game) => convertSteamGameToCatalogueEntry(game)) - ); - return { - results: entries, - cursor: cursor + entries.length, + results: steamGames.map((steamGame) => ({ + title: steamGame.name, + shop: "steam", + cover: steamUrlBuilder.library(steamGame.id), + objectID: steamGame.id, + })), + cursor: cursor + steamGames.length, }; }; diff --git a/src/main/events/catalogue/get-random-game.ts b/src/main/events/catalogue/get-random-game.ts index 72b93c33..0a3797a9 100644 --- a/src/main/events/catalogue/get-random-game.ts +++ b/src/main/events/catalogue/get-random-game.ts @@ -3,32 +3,15 @@ import { shuffle } from "lodash-es"; import { getSteam250List } from "@main/services"; import { registerEvent } from "../register-event"; -import { getSteamGameById } from "../helpers/search-games"; import type { Steam250Game } from "@types"; const state = { games: Array(), index: 0 }; -const filterGames = async (games: Steam250Game[]) => { - const results: Steam250Game[] = []; - - for (const game of games) { - const steamGame = await getSteamGameById(game.objectID); - - if (steamGame?.repacks.length) { - results.push(game); - } - } - - return results; -}; - const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => { if (state.games.length == 0) { const steam250List = await getSteam250List(); - const filteredSteam250List = await filterGames(steam250List); - - state.games = shuffle(filteredSteam250List); + state.games = shuffle(steam250List); } if (state.games.length == 0) { diff --git a/src/main/events/catalogue/search-game-repacks.ts b/src/main/events/catalogue/search-game-repacks.ts deleted file mode 100644 index e3b9c2b5..00000000 --- a/src/main/events/catalogue/search-game-repacks.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RepacksManager } from "@main/services"; -import { registerEvent } from "../register-event"; - -const searchGameRepacks = ( - _event: Electron.IpcMainInvokeEvent, - query: string -) => RepacksManager.search({ query }); - -registerEvent("searchGameRepacks", searchGameRepacks); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts index ebe601f2..8f81d40e 100644 --- a/src/main/events/catalogue/search-games.ts +++ b/src/main/events/catalogue/search-games.ts @@ -1,7 +1,7 @@ import { registerEvent } from "../register-event"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { CatalogueEntry } from "@types"; -import { HydraApi, RepacksManager } from "@main/services"; +import { HydraApi } from "@main/services"; const searchGamesEvent = async ( _event: Electron.IpcMainInvokeEvent, @@ -11,15 +11,13 @@ const searchGamesEvent = async ( { objectId: string; title: string; shop: string }[] >("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false }); - const steamGames = games.map((game) => { + return games.map((game) => { return convertSteamGameToCatalogueEntry({ id: Number(game.objectId), name: game.title, clientIcon: null, }); }); - - return RepacksManager.findRepacksForCatalogueEntries(steamGames); }; registerEvent("searchGames", searchGamesEvent); diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts deleted file mode 100644 index b0c0e470..00000000 --- a/src/main/events/download-sources/add-download-source.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { registerEvent } from "../register-event"; -import { dataSource } from "@main/data-source"; -import { DownloadSource } from "@main/entity"; -import axios from "axios"; -import { downloadSourceSchema } from "../helpers/validators"; -import { insertDownloadsFromSource } from "@main/helpers"; -import { RepacksManager } from "@main/services"; - -const addDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => { - const response = await axios.get(url); - - const source = downloadSourceSchema.parse(response.data); - - const downloadSource = await dataSource.transaction( - async (transactionalEntityManager) => { - const downloadSource = await transactionalEntityManager - .getRepository(DownloadSource) - .save({ - url, - name: source.name, - downloadCount: source.downloads.length, - }); - - await insertDownloadsFromSource( - transactionalEntityManager, - downloadSource, - source.downloads - ); - - return downloadSource; - } - ); - - await RepacksManager.updateRepacks(); - - return downloadSource; -}; - -registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts new file mode 100644 index 00000000..abfbf661 --- /dev/null +++ b/src/main/events/download-sources/delete-download-source.ts @@ -0,0 +1,9 @@ +import { registerEvent } from "../register-event"; +import { knexClient } from "@main/knex-client"; + +const deleteDownloadSource = async ( + _event: Electron.IpcMainInvokeEvent, + id: number +) => knexClient("download_source").where({ id }).delete(); + +registerEvent("deleteDownloadSource", deleteDownloadSource); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index b8565645..97f8a6d8 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,11 +1,7 @@ -import { downloadSourceRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { knexClient } from "@main/knex-client"; const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => - downloadSourceRepository.find({ - order: { - createdAt: "DESC", - }, - }); + knexClient.select("*").from("download_source"); registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts deleted file mode 100644 index 73f2ffbe..00000000 --- a/src/main/events/download-sources/remove-download-source.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { downloadSourceRepository } from "@main/repository"; -import { registerEvent } from "../register-event"; -import { RepacksManager } from "@main/services"; - -const removeDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - id: number -) => { - await downloadSourceRepository.delete(id); - await RepacksManager.updateRepacks(); -}; - -registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts deleted file mode 100644 index 2e000e64..00000000 --- a/src/main/events/download-sources/sync-download-sources.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { registerEvent } from "../register-event"; -import { fetchDownloadSourcesAndUpdate } from "@main/helpers"; - -const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => - fetchDownloadSourcesAndUpdate(); - -registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts deleted file mode 100644 index fdb67961..00000000 --- a/src/main/events/download-sources/validate-download-source.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourceRepository } from "@main/repository"; -import { RepacksManager } from "@main/services"; -import { downloadSourceWorker } from "@main/workers"; - -const validateDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => { - const existingSource = await downloadSourceRepository.findOne({ - where: { url }, - }); - - if (existingSource) - throw new Error("Source with the same url already exists"); - - const repacks = RepacksManager.repacks; - - return downloadSourceWorker.run( - { url, repacks }, - { - name: "validateDownloadSource", - } - ); -}; - -registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index 5fb5098e..1f1fc756 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -1,7 +1,6 @@ import type { GameShop, CatalogueEntry, SteamGame } from "@types"; import { steamGamesWorker } from "@main/workers"; -import { RepacksManager } from "@main/services"; import { steamUrlBuilder } from "@shared"; export interface SearchGamesArgs { @@ -17,7 +16,6 @@ export const convertSteamGameToCatalogueEntry = ( title: game.name, shop: "steam" as GameShop, cover: steamUrlBuilder.library(String(game.id)), - repacks: [], }); export const getSteamGameById = async ( @@ -29,9 +27,5 @@ export const getSteamGameById = async ( if (!steamGame) return null; - const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame); - - const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry); - - return result; + return convertSteamGameToCatalogueEntry(steamGame); }; diff --git a/src/main/events/helpers/validators.ts b/src/main/events/helpers/validators.ts deleted file mode 100644 index ee36bb85..00000000 --- a/src/main/events/helpers/validators.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; - -export const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0337c9d8..f2853cbf 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -7,7 +7,6 @@ import "./catalogue/get-games"; import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; import "./catalogue/search-games"; -import "./catalogue/search-game-repacks"; import "./catalogue/get-game-stats"; import "./catalogue/get-trending-games"; import "./hardware/get-disk-free-space"; @@ -37,11 +36,8 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; +import "./download-sources/delete-download-source"; import "./download-sources/get-download-sources"; -import "./download-sources/validate-download-source"; -import "./download-sources/add-download-source"; -import "./download-sources/remove-download-source"; -import "./download-sources/sync-download-sources"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; @@ -60,6 +56,7 @@ import "./profile/update-profile"; import "./profile/process-profile-image"; import "./profile/send-friend-request"; import "./profile/sync-friend-requests"; +import "./notifications/publish-new-repacks-notification"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 13a7e5e0..b5c9a5d0 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; -import { getFileBase64 } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; @@ -36,20 +35,12 @@ const addGameToLibrary = async ( ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) : null; - await gameRepository - .insert({ - title, - iconUrl, - objectID, - shop, - }) - .then(() => { - if (iconUrl) { - getFileBase64(iconUrl).then((base64) => - gameRepository.update({ objectID }, { iconUrl: base64 }) - ); - } - }); + await gameRepository.insert({ + title, + iconUrl, + objectID, + shop, + }); } const game = await gameRepository.findOne({ where: { objectID } }); diff --git a/src/main/events/notifications/publish-new-repacks-notification.ts b/src/main/events/notifications/publish-new-repacks-notification.ts new file mode 100644 index 00000000..5230c209 --- /dev/null +++ b/src/main/events/notifications/publish-new-repacks-notification.ts @@ -0,0 +1,29 @@ +import { Notification } from "electron"; +import { registerEvent } from "../register-event"; +import { userPreferencesRepository } from "@main/repository"; +import { t } from "i18next"; + +const publishNewRepacksNotification = async ( + _event: Electron.IpcMainInvokeEvent, + newRepacksCount: number +) => { + if (newRepacksCount < 1) return; + + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + + if (userPreferences?.repackUpdatesNotificationsEnabled) { + new Notification({ + title: t("repack_list_updated", { + ns: "notifications", + }), + body: t("repack_count", { + ns: "notifications", + count: newRepacksCount, + }), + }).show(); + } +}; + +registerEvent("publishNewRepacksNotification", publishNewRepacksNotification); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 253ab159..a2c51a01 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,7 +1,6 @@ import { registerEvent } from "../register-event"; import type { StartGameDownloadPayload } from "@types"; -import { getFileBase64 } from "@main/helpers"; import { DownloadManager, HydraApi, logger } from "@main/services"; import { Not } from "typeorm"; @@ -9,36 +8,25 @@ import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; import { steamUrlBuilder } from "@shared"; import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game, Repack } from "@main/entity"; +import { DownloadQueue, Game } from "@main/entity"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, payload: StartGameDownloadPayload ) => { - const { repackId, objectID, title, shop, downloadPath, downloader, uri } = - payload; + const { objectID, title, shop, downloadPath, downloader, uri } = payload; return dataSource.transaction(async (transactionalEntityManager) => { const gameRepository = transactionalEntityManager.getRepository(Game); - const repackRepository = transactionalEntityManager.getRepository(Repack); const downloadQueueRepository = transactionalEntityManager.getRepository(DownloadQueue); - const [game, repack] = await Promise.all([ - gameRepository.findOne({ - where: { - objectID, - shop, - }, - }), - repackRepository.findOne({ - where: { - id: repackId, - }, - }), - ]); - - if (!repack) return; + const game = await gameRepository.findOne({ + where: { + objectID, + shop, + }, + }); await DownloadManager.pauseDownload(); @@ -71,26 +59,16 @@ const startGameDownload = async ( ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) : null; - await gameRepository - .insert({ - title, - iconUrl, - objectID, - downloader, - shop, - status: "active", - downloadPath, - uri, - }) - .then((result) => { - if (iconUrl) { - getFileBase64(iconUrl).then((base64) => - gameRepository.update({ objectID }, { iconUrl: base64 }) - ); - } - - return result; - }); + await gameRepository.insert({ + title, + iconUrl, + objectID, + downloader, + shop, + status: "active", + downloadPath, + uri, + }); } const updatedGame = await gameRepository.findOne({ diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index b8bd7a0a..6bbab9c4 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -73,7 +73,6 @@ const getUser = async ( recentGames, }; } catch (err) { - console.log(err); return null; } }; diff --git a/src/main/helpers/download-source.ts b/src/main/helpers/download-source.ts deleted file mode 100644 index c216212a..00000000 --- a/src/main/helpers/download-source.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { dataSource } from "@main/data-source"; -import { DownloadSource, Repack } from "@main/entity"; -import { downloadSourceSchema } from "@main/events/helpers/validators"; -import { downloadSourceRepository } from "@main/repository"; -import { RepacksManager } from "@main/services"; -import { downloadSourceWorker } from "@main/workers"; -import { chunk } from "lodash-es"; -import type { EntityManager } from "typeorm"; -import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; -import { z } from "zod"; - -export const insertDownloadsFromSource = async ( - trx: EntityManager, - downloadSource: DownloadSource, - downloads: z.infer["downloads"] -) => { - const repacks: QueryDeepPartialEntity[] = downloads.map( - (download) => ({ - title: download.title, - uris: JSON.stringify(download.uris), - magnet: download.uris[0]!, - fileSize: download.fileSize, - repacker: downloadSource.name, - uploadDate: download.uploadDate, - downloadSource: { id: downloadSource.id }, - }) - ); - - const downloadsChunks = chunk(repacks, 800); - - for (const chunk of downloadsChunks) { - await trx - .getRepository(Repack) - .createQueryBuilder() - .insert() - .values(chunk) - .updateEntity(false) - .orIgnore() - .execute(); - } -}; - -export const fetchDownloadSourcesAndUpdate = async () => { - const downloadSources = await downloadSourceRepository.find({ - order: { - id: "desc", - }, - }); - - const results = await downloadSourceWorker.run(downloadSources, { - name: "getUpdatedRepacks", - }); - - await dataSource.transaction(async (transactionalEntityManager) => { - for (const result of results) { - if (result.etag !== null) { - await transactionalEntityManager.getRepository(DownloadSource).update( - { id: result.id }, - { - etag: result.etag, - status: result.status, - downloadCount: result.downloads.length, - } - ); - - await insertDownloadsFromSource( - transactionalEntityManager, - result, - result.downloads - ); - } - } - - await RepacksManager.updateRepacks(); - }); -}; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 91ce0eb9..bf29762a 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) => response.arrayBuffer().then((buffer) => Buffer.from(buffer)) ); -export const getFileBase64 = async (url: string) => - fetch(url, { method: "GET" }).then((response) => - response.arrayBuffer().then((buffer) => { - const base64 = Buffer.from(buffer).toString("base64"); - const contentType = response.headers.get("content-type"); - - return `data:${contentType};base64,${base64}`; - }) - ); - export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -36,6 +26,4 @@ export const requestWebPage = async (url: string) => { }; export const isPortableVersion = () => - process.env.PORTABLE_EXECUTABLE_FILE != null; - -export * from "./download-source"; + process.env.PORTABLE_EXECUTABLE_FILE !== null; diff --git a/src/main/index.ts b/src/main/index.ts index 00311b46..c9e36b2c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, net, protocol } from "electron"; +import { app, BrowserWindow, net, protocol, session } from "electron"; import { init } from "@sentry/electron/main"; import updater from "electron-updater"; import i18n from "i18next"; @@ -68,14 +68,13 @@ const runMigrations = async () => { }); await knexClient.migrate.latest(migrationConfig); - await knexClient.destroy(); }; // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - electronApp.setAppUserModelId("site.hydralauncher.hydra"); + electronApp.setAppUserModelId("gg.hydralauncher.hydra"); protocol.handle("local", (request) => { const filePath = request.url.slice("local:".length); @@ -104,6 +103,46 @@ app.whenReady().then(async () => { WindowManager.createMainWindow(); WindowManager.createSystemTray(userPreferences?.language || "en"); + + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + callback({ + requestHeaders: { + ...details.requestHeaders, + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + }, + }); + }); + + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const headers = { + "access-control-allow-origin": ["*"], + "access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"], + "access-control-expose-headers": ["ETag"], + "access-control-allow-headers": [ + "Content-Type, Authorization, X-Requested-With, If-None-Match", + ], + "access-control-allow-credentials": ["true"], + }; + + if (details.method === "OPTIONS") { + callback({ + cancel: false, + responseHeaders: { + ...details.responseHeaders, + ...headers, + }, + statusLine: "HTTP/1.1 200 OK", + }); + } else { + callback({ + responseHeaders: { + ...details.responseHeaders, + ...headers, + }, + }); + } + }); }); app.on("browser-window-created", (_, window) => { diff --git a/src/main/main.ts b/src/main/main.ts index af594e20..7f3d6370 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,25 +1,14 @@ -import { - DownloadManager, - RepacksManager, - PythonInstance, - startMainLoop, -} from "./services"; +import { DownloadManager, PythonInstance, startMainLoop } from "./services"; import { downloadQueueRepository, - repackRepository, userPreferencesRepository, } from "./repository"; import { UserPreferences } from "./entity"; import { RealDebridClient } from "./services/real-debrid"; -import { fetchDownloadSourcesAndUpdate } from "./helpers"; -import { publishNewRepacksNotifications } from "./services/notifications"; -import { MoreThan } from "typeorm"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; const loadState = async (userPreferences: UserPreferences | null) => { - RepacksManager.updateRepacks(); - import("./events"); if (userPreferences?.realDebridApiToken) { @@ -46,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => { } startMainLoop(); - - const now = new Date(); - - fetchDownloadSourcesAndUpdate().then(async () => { - const newRepacksCount = await repackRepository.count({ - where: { - createdAt: MoreThan(now), - }, - }); - - if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount); - }); }; userPreferencesRepository diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 255b3871..8664062f 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -7,5 +7,4 @@ export * from "./download"; export * from "./how-long-to-beat"; export * from "./process-watcher"; export * from "./main-loop"; -export * from "./repacks-manager"; export * from "./hydra-api"; diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts index aa43571d..81d9e582 100644 --- a/src/main/services/notifications.ts +++ b/src/main/services/notifications.ts @@ -49,24 +49,6 @@ export const publishDownloadCompleteNotification = async (game: Game) => { } }; -export const publishNewRepacksNotifications = async (count: number) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - if (userPreferences?.repackUpdatesNotificationsEnabled) { - new Notification({ - title: t("repack_list_updated", { - ns: "notifications", - }), - body: t("repack_count", { - ns: "notifications", - count: count, - }), - }).show(); - } -}; - export const publishNotificationUpdateReadyToInstall = async ( version: string ) => { diff --git a/src/main/services/repacks-manager.ts b/src/main/services/repacks-manager.ts deleted file mode 100644 index 933d7431..00000000 --- a/src/main/services/repacks-manager.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { repackRepository } from "@main/repository"; -import { formatName } from "@shared"; -import { CatalogueEntry, GameRepack } from "@types"; -import flexSearch from "flexsearch"; - -export class RepacksManager { - public static repacks: GameRepack[] = []; - private static repacksIndex = new flexSearch.Index(); - - public static async updateRepacks() { - this.repacks = await repackRepository - .find({ - order: { - createdAt: "DESC", - }, - }) - .then((repacks) => - repacks.map((repack) => { - const uris: string[] = []; - const magnet = repack?.magnet; - - if (magnet) uris.push(magnet); - - return { - ...repack, - uris: [...uris, ...JSON.parse(repack.uris)], - }; - }) - ); - - for (let i = 0; i < this.repacks.length; i++) { - this.repacksIndex.remove(i); - } - - this.repacksIndex = new flexSearch.Index(); - - for (let i = 0; i < this.repacks.length; i++) { - const repack = this.repacks[i]; - - const formattedTitle = formatName(repack.title); - - this.repacksIndex.add(i, formattedTitle); - } - } - - public static search(options: flexSearch.SearchOptions) { - return this.repacksIndex - .search({ ...options, query: formatName(options.query ?? "") }) - .map((index) => this.repacks[index]); - } - - public static findRepacksForCatalogueEntry(entry: CatalogueEntry) { - const repacks = this.search({ query: formatName(entry.title) }); - return { ...entry, repacks }; - } - - public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) { - return entries.map((entry) => { - const repacks = this.search({ query: formatName(entry.title) }); - return { ...entry, repacks }; - }); - } -} diff --git a/src/main/workers/download-source.worker.ts b/src/main/workers/download-source.worker.ts deleted file mode 100644 index 5ec37c7f..00000000 --- a/src/main/workers/download-source.worker.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { downloadSourceSchema } from "@main/events/helpers/validators"; -import { DownloadSourceStatus } from "@shared"; -import type { DownloadSource, GameRepack } from "@types"; -import axios, { AxiosError, AxiosHeaders } from "axios"; -import { z } from "zod"; - -export type DownloadSourceResponse = z.infer & { - etag: string | null; - status: DownloadSourceStatus; -}; - -export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => { - const results: DownloadSourceResponse[] = []; - - for (const downloadSource of downloadSources) { - const headers = new AxiosHeaders(); - - if (downloadSource.etag) { - headers.set("If-None-Match", downloadSource.etag); - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - - results.push({ - ...downloadSource, - downloads: source.downloads, - etag: response.headers["etag"], - status: DownloadSourceStatus.UpToDate, - }); - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - results.push({ - ...downloadSource, - downloads: [], - etag: null, - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); - } - } - - return results; -}; - -export const validateDownloadSource = async ({ - url, - repacks, -}: { - url: string; - repacks: GameRepack[]; -}) => { - const response = await axios.get(url); - - const source = downloadSourceSchema.parse(response.data); - - const existingUris = source.downloads - .flatMap((download) => download.uris) - .filter((uri) => repacks.some((repack) => repack.magnet === uri)); - - return { - name: source.name, - downloadCount: source.downloads.length - existingUris.length, - }; -}; diff --git a/src/main/workers/index.ts b/src/main/workers/index.ts index b0f9721f..eded03a3 100644 --- a/src/main/workers/index.ts +++ b/src/main/workers/index.ts @@ -1,6 +1,5 @@ import path from "node:path"; import steamGamesWorkerPath from "./steam-games.worker?modulePath"; -import downloadSourceWorkerPath from "./download-source.worker?modulePath"; import Piscina from "piscina"; @@ -13,7 +12,3 @@ export const steamGamesWorker = new Piscina({ }, maxThreads: 1, }); - -export const downloadSourceWorker = new Piscina({ - filename: downloadSourceWorkerPath, -}); diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f135b99..5b35958b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -60,13 +60,8 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - validateDownloadSource: (url: string) => - ipcRenderer.invoke("validateDownloadSource", url), - addDownloadSource: (url: string) => - ipcRenderer.invoke("addDownloadSource", url), - removeDownloadSource: (id: number) => - ipcRenderer.invoke("removeDownloadSource", id), - syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + deleteDownloadSource: (id: number) => + ipcRenderer.invoke("deleteDownloadSource", id), /* Library */ addGameToLibrary: (objectID: string, title: string, shop: GameShop) => @@ -182,4 +177,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-signout", listener); return () => ipcRenderer.removeListener("on-signout", listener); }, + + /* Notifications */ + publishNewRepacksNotification: (newRepacksCount: number) => + ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 5b9e44ca..37e63154 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useContext, useEffect, useRef } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; @@ -26,6 +26,8 @@ import { } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; +import { downloadSourcesWorker } from "./workers"; +import { repacksContext } from "./context"; export interface AppProps { children: React.ReactNode; @@ -37,8 +39,12 @@ export function App() { const { t } = useTranslation("app"); + const downloadSourceMigrationLock = useRef(false); + const { clearDownload, setLastPacket } = useDownload(); + const { indexRepacks } = useContext(repacksContext); + const { isFriendsModalVisible, friendRequetsModalTab, @@ -197,7 +203,7 @@ export function App() { useEffect(() => { new MutationObserver(() => { - const modal = document.body.querySelector("[role=modal]"); + const modal = document.body.querySelector("[role=dialog]"); dispatch(toggleDraggingDisabled(Boolean(modal))); }).observe(document.body, { @@ -206,6 +212,49 @@ export function App() { }); }, [dispatch, draggingDisabled]); + useEffect(() => { + if (downloadSourceMigrationLock.current) return; + + downloadSourceMigrationLock.current = true; + + window.electron.getDownloadSources().then(async (downloadSources) => { + if (!downloadSources.length) { + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + channel.onmessage = (event: MessageEvent) => { + const newRepacksCount = event.data; + window.electron.publishNewRepacksNotification(newRepacksCount); + }; + + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + } + + for (const downloadSource of downloadSources) { + const channel = new BroadcastChannel( + `download_sources:import:${downloadSource.url}` + ); + await new Promise((resolve) => { + downloadSourcesWorker.postMessage([ + "IMPORT_DOWNLOAD_SOURCE", + downloadSource.url, + ]); + + channel.onmessage = () => { + window.electron.deleteDownloadSource(downloadSource.id).then(() => { + resolve(true); + }); + + indexRepacks(); + channel.close(); + }; + }).catch(() => channel.close()); + } + + downloadSourceMigrationLock.current = false; + }); + }, [indexRepacks]); + const handleToastClose = useCallback(() => { dispatch(closeToast()); }, [dispatch]); diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 7181e9b3..9d54bad8 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -1,13 +1,14 @@ import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; -import type { CatalogueEntry, GameStats } from "@types"; +import type { CatalogueEntry, GameRepack, GameStats } from "@types"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import * as styles from "./game-card.css"; import { useTranslation } from "react-i18next"; import { Badge } from "../badge/badge"; -import { useCallback, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useFormat } from "@renderer/hooks"; +import { repacksContext } from "@renderer/context"; export interface GameCardProps extends React.DetailedHTMLProps< @@ -25,9 +26,20 @@ export function GameCard({ game, ...props }: GameCardProps) { const { t } = useTranslation("game_card"); const [stats, setStats] = useState(null); + const [repacks, setRepacks] = useState([]); + + const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); + + useEffect(() => { + if (!isIndexingRepacks) { + searchRepacks(game.title).then((repacks) => { + setRepacks(repacks); + }); + } + }, [game, isIndexingRepacks, searchRepacks]); const uniqueRepackers = Array.from( - new Set(game.repacks.map(({ repacker }) => repacker)) + new Set(repacks.map(({ repacker }) => repacker)) ); const handleHover = useCallback(() => { 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 e723779f..120728b1 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,4 +1,10 @@ -import { createContext, useCallback, useEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { setHeaderTitle } from "@renderer/features"; @@ -16,6 +22,7 @@ import type { import { useTranslation } from "react-i18next"; import { GameDetailsContext } from "./game-details.context.types"; import { SteamContentDescriptor } from "@shared"; +import { repacksContext } from "../repacks/repacks.context"; export const gameDetailsContext = createContext({ game: null, @@ -52,7 +59,6 @@ export function GameDetailsContextProvider({ const { objectID, shop } = useParams(); const [shopDetails, setShopDetails] = useState(null); - const [repacks, setRepacks] = useState([]); const [game, setGame] = useState(null); const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false); @@ -64,10 +70,22 @@ export function GameDetailsContextProvider({ const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); + const [repacks, setRepacks] = useState([]); + const [searchParams] = useSearchParams(); const gameTitle = searchParams.get("title")!; + const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); + + useEffect(() => { + if (!isIndexingRepacks) { + searchRepacks(gameTitle).then((repacks) => { + setRepacks(repacks); + }); + } + }, [game, gameTitle, isIndexingRepacks, searchRepacks]); + const { i18n } = useTranslation("game_details"); const dispatch = useAppDispatch(); @@ -91,37 +109,31 @@ export function GameDetailsContextProvider({ }, [updateGame, isGameDownloading, lastPacket?.game.status]); useEffect(() => { - Promise.allSettled([ - window.electron.getGameShopDetails( + window.electron + .getGameShopDetails( objectID!, shop as GameShop, getSteamLanguage(i18n.language) - ), - window.electron.searchGameRepacks(gameTitle), - window.electron.getGameStats(objectID!, shop as GameShop), - ]) - .then(([appDetailsResult, repacksResult, statsResult]) => { - if (appDetailsResult.status === "fulfilled") { - setShopDetails(appDetailsResult.value); + ) + .then((result) => { + setShopDetails(result); - if ( - appDetailsResult.value?.content_descriptors.ids.includes( - SteamContentDescriptor.AdultOnlySexualContent - ) - ) { - setHasNSFWContentBlocked(true); - } + if ( + result?.content_descriptors.ids.includes( + SteamContentDescriptor.AdultOnlySexualContent + ) + ) { + setHasNSFWContentBlocked(true); } - - if (repacksResult.status === "fulfilled") - setRepacks(repacksResult.value); - - if (statsResult.status === "fulfilled") setStats(statsResult.value); }) .finally(() => { setIsLoading(false); }); + window.electron.getGameStats(objectID!, shop as GameShop).then((result) => { + setStats(result); + }); + updateGame(); }, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]); diff --git a/src/renderer/src/context/index.ts b/src/renderer/src/context/index.ts index d9c1c7e4..8d8b9223 100644 --- a/src/renderer/src/context/index.ts +++ b/src/renderer/src/context/index.ts @@ -1,3 +1,4 @@ export * from "./game-details/game-details.context"; export * from "./settings/settings.context"; export * from "./user-profile/user-profile.context"; +export * from "./repacks/repacks.context"; diff --git a/src/renderer/src/context/repacks/repacks.context.tsx b/src/renderer/src/context/repacks/repacks.context.tsx new file mode 100644 index 00000000..cddbb209 --- /dev/null +++ b/src/renderer/src/context/repacks/repacks.context.tsx @@ -0,0 +1,67 @@ +import type { GameRepack } from "@types"; +import { createContext, useCallback, useEffect, useState } from "react"; + +import { repacksWorker } from "@renderer/workers"; + +export interface RepacksContext { + searchRepacks: (query: string) => Promise; + indexRepacks: () => void; + isIndexingRepacks: boolean; +} + +export const repacksContext = createContext({ + searchRepacks: async () => [] as GameRepack[], + indexRepacks: () => {}, + isIndexingRepacks: false, +}); + +const { Provider } = repacksContext; +export const { Consumer: RepacksContextConsumer } = repacksContext; + +export interface RepacksContextProps { + children: React.ReactNode; +} + +export function RepacksContextProvider({ children }: RepacksContextProps) { + const [isIndexingRepacks, setIsIndexingRepacks] = useState(true); + + const searchRepacks = useCallback(async (query: string) => { + return new Promise((resolve) => { + const channelId = crypto.randomUUID(); + repacksWorker.postMessage([channelId, query]); + + const channel = new BroadcastChannel(`repacks:search:${channelId}`); + channel.onmessage = (event: MessageEvent) => { + resolve(event.data); + channel.close(); + }; + + return []; + }); + }, []); + + const indexRepacks = useCallback(() => { + setIsIndexingRepacks(true); + repacksWorker.postMessage("INDEX_REPACKS"); + + repacksWorker.onmessage = () => { + setIsIndexingRepacks(false); + }; + }, []); + + useEffect(() => { + indexRepacks(); + }, [indexRepacks]); + + return ( + + {children} + + ); +} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 70b77eec..2d1d8663 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -102,12 +102,7 @@ declare global { /* Download sources */ getDownloadSources: () => Promise; - validateDownloadSource: ( - url: string - ) => Promise<{ name: string; downloadCount: number }>; - addDownloadSource: (url: string) => Promise; - removeDownloadSource: (id: number) => Promise; - syncDownloadSources: () => Promise; + deleteDownloadSource: (id: number) => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; @@ -171,6 +166,9 @@ declare global { action: FriendRequestAction ) => Promise; sendFriendRequest: (userId: string) => Promise; + + /* Notifications */ + publishNewRepacksNotification: (newRepacksCount: number) => Promise; } interface Window { diff --git a/src/renderer/src/dexie.ts b/src/renderer/src/dexie.ts new file mode 100644 index 00000000..23f0bf83 --- /dev/null +++ b/src/renderer/src/dexie.ts @@ -0,0 +1,13 @@ +import { Dexie } from "dexie"; + +export const db = new Dexie("Hydra"); + +db.version(1).stores({ + repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`, + downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`, +}); + +export const downloadSourcesTable = db.table("downloadSources"); +export const repacksTable = db.table("repacks"); + +db.open(); diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index d92038aa..50e2fad9 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -90,19 +90,9 @@ export function useUserDetails() { username: userDetails?.username || "", }); }, - [updateUserDetails] + [updateUserDetails, userDetails?.username] ); - const fetchFriendRequests = useCallback(async () => { - return window.electron - .getFriendRequests() - .then((friendRequests) => { - syncFriendRequests(); - dispatch(setFriendRequests(friendRequests)); - }) - .catch(() => {}); - }, [dispatch]); - const syncFriendRequests = useCallback(async () => { return window.electron .syncFriendRequests() @@ -112,6 +102,16 @@ export function useUserDetails() { .catch(() => {}); }, [dispatch]); + const fetchFriendRequests = useCallback(async () => { + return window.electron + .getFriendRequests() + .then((friendRequests) => { + syncFriendRequests(); + dispatch(setFriendRequests(friendRequests)); + }) + .catch(() => {}); + }, [dispatch, syncFriendRequests]); + const showFriendsModal = useCallback( (initialTab: UserFriendModalTab, userId: string) => { dispatch(setFriendsModalVisible({ initialTab, userId })); diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index b98d5ed9..d845e028 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -29,6 +29,9 @@ import { store } from "./store"; import resources from "@locales"; +import "./workers"; +import { RepacksContextProvider } from "./context"; + Sentry.init({}); i18n @@ -54,19 +57,21 @@ i18n ReactDOM.createRoot(document.getElementById("root")!).render( - - - }> - - - - - - - - - - + + + + }> + + + + + + + + + + + ); diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 0d1b9c1d..9d8a1a11 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -1,6 +1,5 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import parseTorrent from "parse-torrent"; import { Badge, Button, Modal, TextField } from "@renderer/components"; import type { GameRepack } from "@types"; @@ -33,8 +32,6 @@ export function RepacksModal({ const [repack, setRepack] = useState(null); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); - const [infoHash, setInfoHash] = useState(null); - const { repacks, game } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -43,18 +40,9 @@ export function RepacksModal({ return orderBy(repacks, (repack) => repack.uploadDate, "desc"); }, [repacks]); - const getInfoHash = useCallback(async () => { - if (game?.uri?.startsWith("magnet:")) { - const torrent = await parseTorrent(game?.uri ?? ""); - if (torrent.infoHash) setInfoHash(torrent.infoHash); - } - }, [game]); - useEffect(() => { setFilteredRepacks(sortedRepacks); - - if (game?.uri) getInfoHash(); - }, [sortedRepacks, visible, game, getInfoHash]); + }, [sortedRepacks, visible, game]); const handleRepackClick = (repack: GameRepack) => { setRepack(repack); @@ -77,9 +65,6 @@ export function RepacksModal({ }; const checkIfLastDownloadedOption = (repack: GameRepack) => { - if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash)); - if (!game?.uri) return false; - return repack.uris.some((uri) => uri.includes(game?.uri ?? "")); }; diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index 015ee0dc..5ec22827 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -8,6 +8,9 @@ import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; +import { downloadSourcesTable } from "@renderer/dexie"; +import { DownloadSourceValidationResult } from "@types"; +import { downloadSourcesWorker } from "@renderer/workers"; interface AddDownloadSourceModalProps { visible: boolean; @@ -39,41 +42,48 @@ export function AddDownloadSourceModal({ setValue, setError, clearErrors, - formState: { errors }, + formState: { errors, isSubmitting }, } = useForm({ resolver: yupResolver(schema), }); - const [validationResult, setValidationResult] = useState<{ - name: string; - downloadCount: number; - } | null>(null); + const [validationResult, setValidationResult] = + useState(null); const { sourceUrl } = useContext(settingsContext); const onSubmit = useCallback( async (values: FormValues) => { - setIsLoading(true); + const existingDownloadSource = await downloadSourcesTable + .where({ url: values.url }) + .first(); - try { - const result = await window.electron.validateDownloadSource(values.url); - setValidationResult(result); + if (existingDownloadSource) { + setError("url", { + type: "server", + message: t("source_already_exists"), + }); - setUrl(values.url); - } catch (error: unknown) { - if (error instanceof Error) { - if ( - error.message.endsWith("Source with the same url already exists") - ) { - setError("url", { - type: "server", - message: t("source_already_exists"), - }); - } - } - } finally { - setIsLoading(false); + return; } + + downloadSourcesWorker.postMessage([ + "VALIDATE_DOWNLOAD_SOURCE", + values.url, + ]); + + const channel = new BroadcastChannel( + `download_sources:validate:${values.url}` + ); + + channel.onmessage = ( + event: MessageEvent + ) => { + setValidationResult(event.data); + channel.close(); + }; + + setUrl(values.url); }, [setError, t] ); @@ -91,9 +101,21 @@ export function AddDownloadSourceModal({ }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); const handleAddDownloadSource = async () => { - await window.electron.addDownloadSource(url); - onClose(); - onAddDownloadSource(); + setIsLoading(true); + + if (validationResult) { + const channel = new BroadcastChannel(`download_sources:import:${url}`); + + downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); + + channel.onmessage = () => { + setIsLoading(false); + + onClose(); + onAddDownloadSource(); + channel.close(); + }; + } }; return ( @@ -122,7 +144,7 @@ export function AddDownloadSourceModal({ theme="outline" style={{ alignSelf: "flex-end" }} onClick={handleSubmit(onSubmit)} - disabled={isLoading} + disabled={isSubmitting || isLoading} > {t("validate_download_source")} diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 1646af22..d2f45329 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -10,7 +10,9 @@ import { AddDownloadSourceModal } from "./add-download-source-modal"; import { useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; import { SPACING_UNIT } from "@renderer/theme.css"; -import { settingsContext } from "@renderer/context"; +import { repacksContext, settingsContext } from "@renderer/context"; +import { downloadSourcesTable } from "@renderer/dexie"; +import { downloadSourcesWorker } from "@renderer/workers"; export function SettingsDownloadSources() { const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = @@ -18,16 +20,23 @@ export function SettingsDownloadSources() { const [downloadSources, setDownloadSources] = useState([]); const [isSyncingDownloadSources, setIsSyncingDownloadSources] = useState(false); + const [isRemovingDownloadSource, setIsRemovingDownloadSource] = + useState(false); const { sourceUrl, clearSourceUrl } = useContext(settingsContext); const { t } = useTranslation("settings"); const { showSuccessToast } = useToast(); + const { indexRepacks } = useContext(repacksContext); + const getDownloadSources = async () => { - return window.electron.getDownloadSources().then((sources) => { - setDownloadSources(sources); - }); + await downloadSourcesTable + .toCollection() + .sortBy("createdAt") + .then((sources) => { + setDownloadSources(sources.reverse()); + }); }; useEffect(() => { @@ -38,14 +47,24 @@ export function SettingsDownloadSources() { if (sourceUrl) setShowAddDownloadSourceModal(true); }, [sourceUrl]); - const handleRemoveSource = async (id: number) => { - await window.electron.removeDownloadSource(id); - showSuccessToast(t("removed_download_source")); + const handleRemoveSource = (id: number) => { + setIsRemovingDownloadSource(true); + const channel = new BroadcastChannel(`download_sources:delete:${id}`); - getDownloadSources(); + downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]); + + channel.onmessage = () => { + showSuccessToast(t("removed_download_source")); + + getDownloadSources(); + indexRepacks(); + setIsRemovingDownloadSource(false); + channel.close(); + }; }; const handleAddDownloadSource = async () => { + indexRepacks(); await getDownloadSources(); showSuccessToast(t("added_download_source")); }; @@ -53,15 +72,17 @@ export function SettingsDownloadSources() { const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); - window.electron - .syncDownloadSources() - .then(() => { - showSuccessToast(t("download_sources_synced")); - getDownloadSources(); - }) - .finally(() => { - setIsSyncingDownloadSources(false); - }); + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + + channel.onmessage = () => { + showSuccessToast(t("download_sources_synced")); + getDownloadSources(); + setIsSyncingDownloadSources(false); + channel.close(); + }; }; const statusTitle = { @@ -88,7 +109,11 @@ export function SettingsDownloadSources() {