diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index f9ba9d66..ca942854 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -125,6 +125,10 @@ cd hydra yarn ``` +### Instale OpenSSL 1.1 + +[OpenSSL 1.1](https://slproweb.com/download/Win64OpenSSL-1_1_1w.exe) é exigido pelo libtorrent em ambientes Windows. + ### Instale Python 3.9 Certifique-se de ter o Python 3.9 instalado em sua máquina. Você pode baixá-lo e instalá-lo em [python.org](https://www.python.org/downloads/release/python-3913/). diff --git a/electron.vite.config.ts b/electron.vite.config.ts index cd08b6d4..2b7048c4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -38,6 +38,13 @@ export default defineConfig(({ mode }) => { build: { sourcemap: true, }, + css: { + preprocessorOptions: { + scss: { + api: "modern", + }, + }, + }, resolve: { alias: { "@renderer": resolve("src/renderer/src"), diff --git a/package.json b/package.json index 2895f20c..f7fac4ca 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "auto-launch": "^5.0.6", "axios": "^1.7.9", "better-sqlite3": "^11.7.0", + "classic-level": "^2.0.0", "classnames": "^2.5.1", "color": "^4.2.3", "color.js": "^1.2.0", @@ -74,7 +75,6 @@ "sound-play": "^1.1.0", "sudo-prompt": "^9.2.1", "tar": "^7.4.3", - "typeorm": "^0.3.20", "user-agents": "^1.1.387", "yaml": "^2.6.1", "yup": "^1.5.0", diff --git a/python_rpc/main.py b/python_rpc/main.py index 7b2c54b9..2deb2029 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -94,7 +94,7 @@ def seed_status(): @app.route("/healthcheck", methods=["GET"]) def healthcheck(): - return "", 200 + return "ok", 200 @app.route("/process-list", methods=["GET"]) def process_list(): diff --git a/scripts/upload-build.cjs b/scripts/upload-build.cjs index 37fcd7a1..f950908f 100644 --- a/scripts/upload-build.cjs +++ b/scripts/upload-build.cjs @@ -49,14 +49,14 @@ fs.readdir(dist, async (err, files) => { }) ); - if (uploads.length > 0) { + for (const upload of uploads) { await fetch(process.env.BUILD_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - uploads, + upload, branchName: process.env.BRANCH_NAME, version: packageJson.version, githubActor: process.env.GITHUB_ACTOR, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 233d04e4..cf7a313e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -281,7 +281,23 @@ "disable_nsfw_alert": "Disable NSFW alert", "seed_after_download_complete": "Seed after download complete", "show_hidden_achievement_description": "Show hidden achievements description before unlocking them", - "debrid_services": "Debrid Services" + "debrid_services": "Debrid Services", + "account": "Account", + "no_users_blocked": "You have no blocked users", + "subscription_active_until": "Your Hydra Cloud is active until {{date}}", + "manage_subscription": "Manage subscription", + "update_email": "Update email", + "update_password": "Update password", + "current_email": "Current email:", + "no_email_account": "You have not set an email yet", + "account_data_updated_successfully": "Account data updated successfully", + "renew_subscription": "Renew Hydra Cloud", + "subscription_expired_at": "Your subscription expired at {{date}}", + "no_subscription": "Enjoy Hydra in the best possible way", + "become_subscriber": "Be Hydra Cloud", + "subscription_renew_cancelled": "Automatic renewal is disabled", + "subscription_renews_on": "Your subscription renews on {{date}}", + "bill_sent_until": "Your next bill will be sent until this day" }, "notifications": { "download_complete": "Download complete", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 453aff7c..6392937e 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -172,7 +172,8 @@ "reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}", "reset_achievements_title": "Tem certeza?", "reset_achievements_success": "Conquistas resetadas com sucesso", - "reset_achievements_error": "Falha ao resetar conquistas" + "reset_achievements_error": "Falha ao resetar conquistas", + "no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais." }, "activation": { "title": "Ativação", @@ -269,7 +270,23 @@ "disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado", "seed_after_download_complete": "Semear após a conclusão do download", "show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de debloqueá-las", - "debrid_services": "Serviços Debrid" + "debrid_services": "Serviços Debrid", + "account": "Conta", + "no_users_blocked": "Você não bloqueou nenhum usuário", + "subscription_active_until": "Sua assinatura Hydra Cloud ficará ativa até {{date}}", + "manage_subscription": "Gerenciar assinatura", + "update_email": "Atualizar email", + "update_password": "Atualizar senha", + "current_email": "Email atual:", + "no_email_account": "Você ainda não adicionou um email a sua conta", + "account_data_updated_successfully": "Dados da conta atualizados com sucesso", + "renew_subscription": "Renovar Hydra Cloud", + "subscription_expired_at": "Sua assinatura expirou em {{date}}", + "no_subscription": "Aproveite o Hydra da melhor forma possível", + "become_subscriber": "Seja Hydra Cloud", + "subscription_renew_cancelled": "A renovação automática está desativada", + "subscription_renews_on": "Sua assinatura renova dia {{date}}", + "bill_sent_until": "Sua próxima cobrança será enviada até esse dia" }, "notifications": { "download_complete": "Download concluído", @@ -398,7 +415,7 @@ "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos", "achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas", "achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}", - "hidden_achievement_tooltip": "Está é uma conquista oculta", + "hidden_achievement_tooltip": "Esta é uma conquista oculta", "achievement_earn_points": "Ganhe {{points}} pontos com essa conquista", "earned_points": "Pontos ganhos:", "available_points": "Pontos disponíveis:", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 92008a5e..1b48c5e0 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -7,8 +7,8 @@ "featured": "Рекомендации", "surprise_me": "Удиви меня", "no_results": "Ничего не найдено", - "hot": "Сейчас в топе", - "start_typing": "Начинаю вводить текст для поиска...", + "hot": "Сейчас популярно", + "start_typing": "Начинаю вводить текст...", "weekly": "📅 Лучшие игры недели", "achievements": "🏆 Игры, в которых нужно победить" }, @@ -278,7 +278,23 @@ "source_already_exists": "Этот источник уже добавлен", "user_unblocked": "Пользователь разблокирован", "seed_after_download_complete": "Раздавать после завершения загрузки", - "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением" + "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", + "account": "Аккаунт", + "no_users_blocked": "У вас нет заблокированных пользователей", + "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", + "manage_subscription": "Управлять подпиской", + "update_email": "Обновить электронную почту", + "update_password": "Обновить пароль", + "current_email": "Текущий email:", + "no_email_account": "Вы еще не установили электронную почту", + "account_data_updated_successfully": "Данные учетной записи успешно обновлены", + "renew_subscription": "Обновить подписку Hydra Cloud", + "subscription_expired_at": "Срок действия вашей подписки истек в {{date}}", + "no_subscription": "Наслаждайтесь Hydra по максимуму", + "become_subscriber": "Станьте обладателем Hydra Cloud", + "subscription_renew_cancelled": "Автоматическое продление отключено", + "subscription_renews_on": "Ваша подписка продлевается на {{date}}", + "bill_sent_until": "Ваш следующий счет будет отправлен до этого дня" }, "notifications": { "download_complete": "Загрузка завершена", @@ -408,7 +424,7 @@ "subscribe_now": "Подпишитесь прямо сейчас", "cloud_saving": "Сохранение в облаке", "cloud_achievements": "Сохраняйте свои достижения в облаке", - "animated_profile_picture": "Анимированные фотографии профиля", + "animated_profile_picture": "Анимированные аватарки", "premium_support": "Премиальная поддержка", "show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей", "animated_profile_banner": "Анимированный баннер профиля", diff --git a/src/main/constants.ts b/src/main/constants.ts index b98b5935..66bf7af9 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -7,13 +7,18 @@ export const defaultDownloadsPath = app.getPath("downloads"); export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); +export const levelDatabasePath = path.join( + app.getPath("userData"), + `hydra-db${isStaging ? "-staging" : ""}` +); + export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); export const databasePath = path.join( databaseDirectory, isStaging ? "hydra_test.db" : "hydra.db" ); -export const logsPath = path.join(app.getPath("appData"), "hydra", "logs"); +export const logsPath = path.join(app.getPath("userData"), "logs"); export const seedsPath = app.isPackaged ? path.join(process.resourcesPath, "seeds") diff --git a/src/main/data-source.ts b/src/main/data-source.ts deleted file mode 100644 index 51c8522e..00000000 --- a/src/main/data-source.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DataSource } from "typeorm"; -import { - DownloadQueue, - Game, - GameShopCache, - UserPreferences, - UserAuth, - GameAchievement, - UserSubscription, -} from "@main/entity"; - -import { databasePath } from "./constants"; - -export const dataSource = new DataSource({ - type: "better-sqlite3", - entities: [ - Game, - UserAuth, - UserPreferences, - UserSubscription, - GameShopCache, - DownloadQueue, - GameAchievement, - ], - synchronize: false, - database: databasePath, -}); diff --git a/src/main/entity/download-queue.entity.ts b/src/main/entity/download-queue.entity.ts deleted file mode 100644 index cf618947..00000000 --- a/src/main/entity/download-queue.entity.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - CreateDateColumn, - UpdateDateColumn, - OneToOne, - JoinColumn, -} from "typeorm"; -import type { Game } from "./game.entity"; - -@Entity("download_queue") -export class DownloadQueue { - @PrimaryGeneratedColumn() - id: number; - - @OneToOne("Game", "downloadQueue") - @JoinColumn() - game: Game; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/game-achievements.entity.ts b/src/main/entity/game-achievements.entity.ts deleted file mode 100644 index 0cb15f6e..00000000 --- a/src/main/entity/game-achievements.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity("game_achievement") -export class GameAchievement { - @PrimaryGeneratedColumn() - id: number; - - @Column("text") - objectId: string; - - @Column("text") - shop: string; - - @Column("text", { nullable: true }) - unlockedAchievements: string | null; - - @Column("text", { nullable: true }) - achievements: string | null; -} diff --git a/src/main/entity/game-shop-cache.entity.ts b/src/main/entity/game-shop-cache.entity.ts deleted file mode 100644 index 3382da1c..00000000 --- a/src/main/entity/game-shop-cache.entity.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - Entity, - PrimaryColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; -import type { GameShop } from "@types"; - -@Entity("game_shop_cache") -export class GameShopCache { - @PrimaryColumn("text", { unique: true }) - objectID: string; - - @Column("text") - shop: GameShop; - - @Column("text", { nullable: true }) - serializedData: string; - - /** - * @deprecated Use IndexedDB's `howLongToBeatEntries` instead - */ - @Column("text", { nullable: true }) - howLongToBeatSerializedData: string; - - @Column("text", { nullable: true }) - language: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts deleted file mode 100644 index 0fcdcc77..00000000 --- a/src/main/entity/game.entity.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, -} from "typeorm"; - -import type { GameShop, GameStatus } from "@types"; -import { Downloader } from "@shared"; -import type { DownloadQueue } from "./download-queue.entity"; - -@Entity("game") -export class Game { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { unique: true }) - objectID: string; - - @Column("text", { unique: true, nullable: true }) - remoteId: string | null; - - @Column("text") - title: string; - - @Column("text", { nullable: true }) - iconUrl: string | null; - - @Column("text", { nullable: true }) - folderName: string | null; - - @Column("text", { nullable: true }) - downloadPath: string | null; - - @Column("text", { nullable: true }) - executablePath: string | null; - - @Column("text", { nullable: true }) - launchOptions: string | null; - - @Column("text", { nullable: true }) - winePrefixPath: string | null; - - @Column("int", { default: 0 }) - playTimeInMilliseconds: number; - - @Column("text") - shop: GameShop; - - @Column("text", { nullable: true }) - status: GameStatus | null; - - @Column("int", { default: Downloader.Torrent }) - downloader: Downloader; - - /** - * Progress is a float between 0 and 1 - */ - @Column("float", { default: 0 }) - progress: number; - - @Column("int", { default: 0 }) - bytesDownloaded: number; - - @Column("datetime", { nullable: true }) - lastTimePlayed: Date | null; - - @Column("float", { default: 0 }) - fileSize: number; - - @Column("text", { nullable: true }) - uri: string | null; - - @OneToOne("DownloadQueue", "game") - downloadQueue: DownloadQueue; - - @Column("boolean", { default: false }) - isDeleted: boolean; - - @Column("boolean", { default: false }) - shouldSeed: boolean; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/index.ts b/src/main/entity/index.ts deleted file mode 100644 index 1625ac8a..00000000 --- a/src/main/entity/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./game.entity"; -export * from "./user-auth.entity"; -export * from "./user-preferences.entity"; -export * from "./user-subscription.entity"; -export * from "./game-shop-cache.entity"; -export * from "./game.entity"; -export * from "./game-achievements.entity"; -export * from "./download-queue.entity"; diff --git a/src/main/entity/user-auth.entity.ts b/src/main/entity/user-auth.entity.ts deleted file mode 100644 index f34e23ec..00000000 --- a/src/main/entity/user-auth.entity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, -} from "typeorm"; -import { UserSubscription } from "./user-subscription.entity"; - -@Entity("user_auth") -export class UserAuth { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { default: "" }) - userId: string; - - @Column("text", { default: "" }) - displayName: string; - - @Column("text", { nullable: true }) - profileImageUrl: string | null; - - @Column("text", { nullable: true }) - backgroundImageUrl: string | null; - - @Column("text", { default: "" }) - accessToken: string; - - @Column("text", { default: "" }) - refreshToken: string; - - @Column("int", { default: 0 }) - tokenExpirationTimestamp: number; - - @OneToOne("UserSubscription", "user") - subscription: UserSubscription | null; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/user-preferences.entity.ts b/src/main/entity/user-preferences.entity.ts deleted file mode 100644 index 109ede5f..00000000 --- a/src/main/entity/user-preferences.entity.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; - -@Entity("user_preferences") -export class UserPreferences { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { nullable: true }) - downloadsPath: string | null; - - @Column("text", { default: "en" }) - language: string; - - @Column("text", { nullable: true }) - realDebridApiToken: string | null; - - @Column("text", { nullable: true }) - torBoxApiToken: string | null; - - @Column("boolean", { default: false }) - downloadNotificationsEnabled: boolean; - - @Column("boolean", { default: false }) - repackUpdatesNotificationsEnabled: boolean; - - @Column("boolean", { default: true }) - achievementNotificationsEnabled: boolean; - - @Column("boolean", { default: false }) - preferQuitInsteadOfHiding: boolean; - - @Column("boolean", { default: false }) - runAtStartup: boolean; - - @Column("boolean", { default: false }) - startMinimized: boolean; - - @Column("boolean", { default: false }) - disableNsfwAlert: boolean; - - @Column("boolean", { default: true }) - seedAfterDownloadComplete: boolean; - - @Column("boolean", { default: false }) - showHiddenAchievementsDescription: boolean; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/entity/user-subscription.entity.ts b/src/main/entity/user-subscription.entity.ts deleted file mode 100644 index e74ada48..00000000 --- a/src/main/entity/user-subscription.entity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SubscriptionStatus } from "@types"; -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToOne, - JoinColumn, -} from "typeorm"; -import { UserAuth } from "./user-auth.entity"; - -@Entity("user_subscription") -export class UserSubscription { - @PrimaryGeneratedColumn() - id: number; - - @Column("text", { default: "" }) - subscriptionId: string; - - @OneToOne("UserAuth", "subscription") - @JoinColumn() - user: UserAuth; - - @Column("text", { default: "" }) - status: SubscriptionStatus; - - @Column("text", { default: "" }) - planId: string; - - @Column("text", { default: "" }) - planName: string; - - @Column("datetime", { nullable: true }) - expiresAt: Date | null; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/src/main/events/auth/get-session-hash.ts b/src/main/events/auth/get-session-hash.ts index c9dd39cc..c81e0965 100644 --- a/src/main/events/auth/get-session-hash.ts +++ b/src/main/events/auth/get-session-hash.ts @@ -1,13 +1,19 @@ import jwt from "jsonwebtoken"; -import { userAuthRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { db, levelKeys } from "@main/level"; +import type { Auth } from "@types"; +import { Crypto } from "@main/services"; const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { - const auth = await userAuthRepository.findOne({ where: { id: 1 } }); + const auth = await db.get(levelKeys.auth, { + valueEncoding: "json", + }); if (!auth) return null; - const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload; + const payload = jwt.decode( + Crypto.decrypt(auth.accessToken) + ) as jwt.JwtPayload; if (!payload) return null; diff --git a/src/main/events/auth/open-auth-window.ts b/src/main/events/auth/open-auth-window.ts index e93a5a42..0f5ec371 100644 --- a/src/main/events/auth/open-auth-window.ts +++ b/src/main/events/auth/open-auth-window.ts @@ -1,7 +1,24 @@ +import i18next from "i18next"; import { registerEvent } from "../register-event"; -import { WindowManager } from "@main/services"; +import { HydraApi, WindowManager } from "@main/services"; +import { AuthPage } from "@shared"; -const openAuthWindow = async (_event: Electron.IpcMainInvokeEvent) => - WindowManager.openAuthWindow(); +const openAuthWindow = async ( + _event: Electron.IpcMainInvokeEvent, + page: AuthPage +) => { + const searchParams = new URLSearchParams({ + lng: i18next.language, + }); + + if ([AuthPage.UpdateEmail, AuthPage.UpdatePassword].includes(page)) { + const { accessToken } = await HydraApi.refreshToken().catch(() => { + return { accessToken: "" }; + }); + searchParams.set("token", accessToken); + } + + WindowManager.openAuthWindow(page, searchParams); +}; registerEvent("openAuthWindow", openAuthWindow); diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 05fbaa86..e7f90aa5 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -1,26 +1,25 @@ import { registerEvent } from "../register-event"; import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity"; +import { PythonRPC } from "@main/services/python-rpc"; +import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const signOut = async (_event: Electron.IpcMainInvokeEvent) => { - const databaseOperations = dataSource - .transaction(async (transactionalEntityManager) => { - await transactionalEntityManager.getRepository(DownloadQueue).delete({}); - - await transactionalEntityManager.getRepository(Game).delete({}); - - await transactionalEntityManager - .getRepository(UserAuth) - .delete({ id: 1 }); - - await transactionalEntityManager - .getRepository(UserSubscription) - .delete({ id: 1 }); - }) + const databaseOperations = db + .batch([ + { + type: "del", + key: levelKeys.auth, + }, + { + type: "del", + key: levelKeys.user, + }, + ]) .then(() => { /* Removes all games being played */ gamesPlaytime.clear(); + + return Promise.all([gamesSublevel.clear(), downloadsSublevel.clear()]); }); /* Cancels any ongoing downloads */ diff --git a/src/main/events/autoupdater/check-for-updates.ts b/src/main/events/autoupdater/check-for-updates.ts index 1dcc80f3..7ea60d0b 100644 --- a/src/main/events/autoupdater/check-for-updates.ts +++ b/src/main/events/autoupdater/check-for-updates.ts @@ -1,47 +1,8 @@ -import type { AppUpdaterEvent } from "@types"; import { registerEvent } from "../register-event"; -import updater, { UpdateInfo } from "electron-updater"; -import { WindowManager } from "@main/services"; -import { app } from "electron"; -import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; - -const { autoUpdater } = updater; - -const sendEvent = (event: AppUpdaterEvent) => { - WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); -}; - -const sendEventsForDebug = false; - -const isAutoInstallAvailable = - process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; - -const mockValuesForDebug = () => { - sendEvent({ type: "update-available", info: { version: "1.3.0" } }); - sendEvent({ type: "update-downloaded" }); -}; - -const newVersionInfo = { version: "" }; +import { UpdateManager } from "@main/services/update-manager"; const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { - autoUpdater - .once("update-available", (info: UpdateInfo) => { - sendEvent({ type: "update-available", info }); - newVersionInfo.version = info.version; - }) - .once("update-downloaded", () => { - sendEvent({ type: "update-downloaded" }); - publishNotificationUpdateReadyToInstall(newVersionInfo.version); - }); - - if (app.isPackaged) { - autoUpdater.autoDownload = isAutoInstallAvailable; - autoUpdater.checkForUpdates(); - } else if (sendEventsForDebug) { - mockValuesForDebug(); - } - - return isAutoInstallAvailable; + return UpdateManager.checkForUpdates(); }; registerEvent("checkForUpdates", checkForUpdates); diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index 08366abc..39f8425b 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -1,10 +1,10 @@ -import { gameShopCacheRepository } from "@main/repository"; -import { getSteamAppDetails } from "@main/services"; +import { getSteamAppDetails, logger } from "@main/services"; -import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; +import type { ShopDetails, GameShop } from "@types"; import { registerEvent } from "../register-event"; import { steamGamesWorker } from "@main/workers"; +import { gamesShopCacheSublevel, levelKeys } from "@main/level"; const getLocalizedSteamAppDetails = async ( objectId: string, @@ -39,35 +39,27 @@ const getGameShopDetails = async ( language: string ): Promise => { if (shop === "steam") { - const cachedData = await gameShopCacheRepository.findOne({ - where: { objectID: objectId, language }, - }); + const cachedData = await gamesShopCacheSublevel.get( + levelKeys.gameShopCacheItem(shop, objectId, language) + ); const appDetails = getLocalizedSteamAppDetails(objectId, language).then( (result) => { if (result) { - gameShopCacheRepository.upsert( - { - objectID: objectId, - shop: "steam", - language, - serializedData: JSON.stringify(result), - }, - ["objectID"] - ); + gamesShopCacheSublevel + .put(levelKeys.gameShopCacheItem(shop, objectId, language), result) + .catch((err) => { + logger.error("Could not cache game details", err); + }); } return result; } ); - const cachedGame = cachedData?.serializedData - ? (JSON.parse(cachedData?.serializedData) as SteamAppDetails) - : null; - - if (cachedGame) { + if (cachedData) { return { - ...cachedGame, + ...cachedData, objectId, } as ShopDetails; } diff --git a/src/main/events/catalogue/get-trending-games.ts b/src/main/events/catalogue/get-trending-games.ts index acfebfd6..364ceeb9 100644 --- a/src/main/events/catalogue/get-trending-games.ts +++ b/src/main/events/catalogue/get-trending-games.ts @@ -1,14 +1,14 @@ +import { db, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; -import { userPreferencesRepository } from "@main/repository"; import type { TrendingGame } from "@types"; const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - const language = userPreferences?.language || "en"; + const language = await db + .get(levelKeys.language, { + valueEncoding: "utf-8", + }) + .then((language) => language || "en"); const trendingGames = await HydraApi.get( "/games/trending", diff --git a/src/main/events/cloud-save/get-game-backup-preview.ts b/src/main/events/cloud-save/get-game-backup-preview.ts index daffa487..0dc471e3 100644 --- a/src/main/events/cloud-save/get-game-backup-preview.ts +++ b/src/main/events/cloud-save/get-game-backup-preview.ts @@ -1,19 +1,14 @@ import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { Ludusavi } from "@main/services"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; const getGameBackupPreview = async ( _event: Electron.IpcMainInvokeEvent, objectId: string, shop: GameShop ) => { - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath); }; diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index d39ad177..48a60b5b 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -10,7 +10,7 @@ import os from "node:os"; import { backupsPath } from "@main/constants"; import { app } from "electron"; import { normalizePath } from "@main/helpers"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; const bundleBackup = async ( shop: GameShop, @@ -45,12 +45,7 @@ export const createBackup = async ( shop: GameShop, downloadOptionTitle: string | null ) => { - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const bundleLocation = await bundleBackup( shop, diff --git a/src/main/events/hardware/check-folder-write-permission.ts b/src/main/events/hardware/check-folder-write-permission.ts index c74f01e7..af896e98 100644 --- a/src/main/events/hardware/check-folder-write-permission.ts +++ b/src/main/events/hardware/check-folder-write-permission.ts @@ -1,15 +1,21 @@ import fs from "node:fs"; +import path from "node:path"; import { registerEvent } from "../register-event"; const checkFolderWritePermission = async ( _event: Electron.IpcMainInvokeEvent, - path: string -) => - new Promise((resolve) => { - fs.access(path, fs.constants.W_OK, (err) => { - resolve(!err); - }); - }); + testPath: string +) => { + const testFilePath = path.join(testPath, ".hydra-write-test"); + + try { + fs.writeFileSync(testFilePath, ""); + fs.rmSync(testFilePath); + return true; + } catch (err) { + return false; + } +}; registerEvent("checkFolderWritePermission", checkFolderWritePermission); diff --git a/src/main/events/helpers/generate-lutris-yaml.ts b/src/main/events/helpers/generate-lutris-yaml.ts deleted file mode 100644 index f47a2a68..00000000 --- a/src/main/events/helpers/generate-lutris-yaml.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Document as YMLDocument } from "yaml"; -import { Game } from "@main/entity"; -import path from "node:path"; - -export const generateYML = (game: Game) => { - const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase(); - - const doc = new YMLDocument({ - name: game.title, - game_slug: slugifiedGameTitle, - slug: `${slugifiedGameTitle}-installer`, - version: "Installer", - runner: "wine", - script: { - game: { - prefix: "$GAMEDIR", - arch: "win64", - working_dir: "$GAMEDIR", - }, - installer: [ - { - task: { - name: "create_prefix", - arch: "win64", - prefix: "$GAMEDIR", - }, - }, - { - task: { - executable: path.join( - game.downloadPath!, - game.folderName!, - "setup.exe" - ), - name: "wineexec", - prefix: "$GAMEDIR", - }, - }, - ], - }, - }); - - return doc.toString(); -}; diff --git a/src/main/events/helpers/get-downloads-path.ts b/src/main/events/helpers/get-downloads-path.ts index c78a0ede..782ea599 100644 --- a/src/main/events/helpers/get-downloads-path.ts +++ b/src/main/events/helpers/get-downloads-path.ts @@ -1,12 +1,14 @@ -import { userPreferencesRepository } from "@main/repository"; import { defaultDownloadsPath } from "@main/constants"; +import { db, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; export const getDownloadsPath = async () => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { - id: 1, - }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences && userPreferences.downloadsPath) return userPreferences.downloadsPath; diff --git a/src/main/events/helpers/parse-launch-options.ts b/src/main/events/helpers/parse-launch-options.ts new file mode 100644 index 00000000..89a0c611 --- /dev/null +++ b/src/main/events/helpers/parse-launch-options.ts @@ -0,0 +1,7 @@ +export const parseLaunchOptions = (params?: string | null): string[] => { + if (!params) { + return []; + } + + return params.split(" "); +}; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 898c25cd..e27709e9 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -1,57 +1,55 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; -import type { GameShop } from "@types"; +import type { Game, GameShop } from "@types"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; import { steamUrlBuilder } from "@shared"; import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, + shop: GameShop, objectId: string, - title: string, - shop: GameShop + title: string ) => { - return gameRepository - .update( - { - objectID: objectId, - }, - { - shop, - status: null, - isDeleted: false, - } - ) - .then(async ({ affected }) => { - if (!affected) { - const steamGame = await steamGamesWorker.run(Number(objectId), { - name: "getById", - }); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); - const iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; + if (game) { + await downloadsSublevel.del(gameKey); - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - shop, - }); - } - - const game = await gameRepository.findOne({ - where: { objectID: objectId }, - }); - - updateLocalUnlockedAchivements(game!); - - createGame(game!).catch(() => {}); + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: false, }); + } else { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", + }); + + const iconUrl = steamGame?.clientIcon + ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) + : null; + + const game: Game = { + title, + iconUrl, + objectId, + shop, + remoteId: null, + isDeleted: false, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + }; + + await gamesSublevel.put(levelKeys.game(shop, objectId), game); + + updateLocalUnlockedAchivements(game!); + + createGame(game!).catch(() => {}); + } }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/close-game.ts b/src/main/events/library/close-game.ts index f69bf120..d01f3f4f 100644 --- a/src/main/events/library/close-game.ts +++ b/src/main/events/library/close-game.ts @@ -1,10 +1,11 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { logger } from "@main/services"; import sudo from "sudo-prompt"; import { app } from "electron"; import { PythonRPC } from "@main/services/python-rpc"; import { ProcessPayload } from "@main/services/download/types"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const getKillCommand = (pid: number) => { if (process.platform == "win32") { @@ -16,15 +17,14 @@ const getKillCommand = (pid: number) => { const closeGame = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { const processes = (await PythonRPC.rpc.get("/process-list")).data || []; - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game) return; diff --git a/src/main/events/library/create-game-shortcut.ts b/src/main/events/library/create-game-shortcut.ts index 4e6935f4..6e278871 100644 --- a/src/main/events/library/create-game-shortcut.ts +++ b/src/main/events/library/create-game-shortcut.ts @@ -1,18 +1,18 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -import { IsNull, Not } from "typeorm"; import createDesktopShortcut from "create-desktop-shortcuts"; import path from "node:path"; import { app } from "electron"; import { removeSymbolsFromName } from "@shared"; +import { GameShop } from "@types"; +import { gamesSublevel, levelKeys } from "@main/level"; const createGameShortcut = async ( _event: Electron.IpcMainInvokeEvent, - id: number + shop: GameShop, + objectId: string ): Promise => { - const game = await gameRepository.findOne({ - where: { id, executablePath: Not(IsNull()) }, - }); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); if (game) { const filePath = game.executablePath; diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index bdae9b3e..9c290fe0 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -1,37 +1,27 @@ import path from "node:path"; import fs from "node:fs"; -import { gameRepository } from "@main/repository"; - import { getDownloadsPath } from "../helpers/get-downloads-path"; import { logger } from "@main/services"; import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const deleteGameFolder = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ): Promise => { - const game = await gameRepository.findOne({ - where: [ - { - id: gameId, - isDeleted: false, - status: "removed", - }, - { - id: gameId, - progress: 1, - isDeleted: false, - }, - ], - }); + const downloadKey = levelKeys.game(shop, objectId); - if (!game) return; + const download = await downloadsSublevel.get(downloadKey); - if (game.folderName) { + if (!download) return; + + if (download.folderName) { const folderPath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName + download.downloadPath ?? (await getDownloadsPath()), + download.folderName ); if (fs.existsSync(folderPath)) { @@ -52,10 +42,7 @@ const deleteGameFolder = async ( } } - await gameRepository.update( - { id: gameId }, - { downloadPath: null, folderName: null, status: null, progress: 0 } - ); + await downloadsSublevel.del(downloadKey); }; registerEvent("deleteGameFolder", deleteGameFolder); diff --git a/src/main/events/library/get-game-by-object-id.ts b/src/main/events/library/get-game-by-object-id.ts index d68aac69..239bcb8d 100644 --- a/src/main/events/library/get-game-by-object-id.ts +++ b/src/main/events/library/get-game-by-object-id.ts @@ -1,16 +1,21 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; +import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const getGameByObjectId = async ( _event: Electron.IpcMainInvokeEvent, + shop: GameShop, objectId: string -) => - gameRepository.findOne({ - where: { - objectID: objectId, - isDeleted: false, - }, - }); +) => { + const gameKey = levelKeys.game(shop, objectId); + const [game, download] = await Promise.all([ + gamesSublevel.get(gameKey), + downloadsSublevel.get(gameKey), + ]); + + if (!game || game.isDeleted) return null; + + return { id: gameKey, ...game, download }; +}; registerEvent("getGameByObjectId", getGameByObjectId); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ad982308..86c0fd29 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -1,17 +1,26 @@ -import { gameRepository } from "@main/repository"; +import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; +import { downloadsSublevel, gamesSublevel } from "@main/level"; -const getLibrary = async () => - gameRepository.find({ - where: { - isDeleted: false, - }, - relations: { - downloadQueue: true, - }, - order: { - createdAt: "desc", - }, - }); +const getLibrary = async (): Promise => { + return gamesSublevel + .iterator() + .all() + .then((results) => { + return Promise.all( + results + .filter(([_key, game]) => game.isDeleted === false) + .map(async ([key, game]) => { + const download = await downloadsSublevel.get(key); + + return { + id: key, + ...game, + download: download ?? null, + }; + }) + ); + }); +}; registerEvent("getLibrary", getLibrary); diff --git a/src/main/events/library/open-game-executable-path.ts b/src/main/events/library/open-game-executable-path.ts index 09a0837c..96a993a6 100644 --- a/src/main/events/library/open-game-executable-path.ts +++ b/src/main/events/library/open-game-executable-path.ts @@ -1,14 +1,14 @@ import { shell } from "electron"; -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const openGameExecutablePath = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game || !game.executablePath) return; diff --git a/src/main/events/library/open-game-installer-path.ts b/src/main/events/library/open-game-installer-path.ts index dd7383ad..b61246fa 100644 --- a/src/main/events/library/open-game-installer-path.ts +++ b/src/main/events/library/open-game-installer-path.ts @@ -1,22 +1,22 @@ import { shell } from "electron"; import path from "node:path"; -import { gameRepository } from "@main/repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const openGameInstallerPath = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); - if (!game || !game.folderName || !game.downloadPath) return true; + if (!download || !download.folderName || !download.downloadPath) return true; const gamePath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName! + download.downloadPath ?? (await getDownloadsPath()), + download.folderName! ); shell.showItemInFolder(gamePath); diff --git a/src/main/events/library/open-game-installer.ts b/src/main/events/library/open-game-installer.ts index b21a6f16..9cf1d978 100644 --- a/src/main/events/library/open-game-installer.ts +++ b/src/main/events/library/open-game-installer.ts @@ -1,14 +1,12 @@ import { shell } from "electron"; import path from "node:path"; import fs from "node:fs"; -import { writeFile } from "node:fs/promises"; import { spawnSync, exec } from "node:child_process"; -import { gameRepository } from "@main/repository"; - -import { generateYML } from "../helpers/generate-lutris-yaml"; import { getDownloadsPath } from "../helpers/get-downloads-path"; import { registerEvent } from "../register-event"; +import { downloadsSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const executeGameInstaller = (filePath: string) => { if (process.platform === "win32") { @@ -26,21 +24,21 @@ const executeGameInstaller = (filePath: string) => { const openGameInstaller = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); + const downloadKey = levelKeys.game(shop, objectId); + const download = await downloadsSublevel.get(downloadKey); - if (!game || !game.folderName) return true; + if (!download?.folderName) return true; const gamePath = path.join( - game.downloadPath ?? (await getDownloadsPath()), - game.folderName! + download.downloadPath ?? (await getDownloadsPath()), + download.folderName ); if (!fs.existsSync(gamePath)) { - await gameRepository.update({ id: gameId }, { status: null }); + await downloadsSublevel.del(downloadKey); return true; } @@ -70,13 +68,6 @@ const openGameInstaller = async ( ); } - if (spawnSync("which", ["lutris"]).status === 0) { - const ymlPath = path.join(gamePath, "setup.yml"); - await writeFile(ymlPath, generateYML(game)); - exec(`lutris --install "${ymlPath}"`); - return true; - } - shell.openPath(gamePath); return true; }; diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index cf73c810..64e3d5fb 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -1,24 +1,39 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; import { shell } from "electron"; +import { spawn } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; +import { parseLaunchOptions } from "../helpers/parse-launch-options"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number, + shop: GameShop, + objectId: string, executablePath: string, - launchOptions: string | null + launchOptions?: string | null ) => { - // TODO: revisit this for launchOptions const parsedPath = parseExecutablePath(executablePath); + const parsedParams = parseLaunchOptions(launchOptions); - await gameRepository.update( - { id: gameId }, - { executablePath: parsedPath, launchOptions } - ); + const gameKey = levelKeys.game(shop, objectId); - shell.openPath(parsedPath); + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + executablePath: parsedPath, + launchOptions, + }); + + if (parsedParams.length === 0) { + shell.openPath(parsedPath); + return; + } + + spawn(parsedPath, parsedParams, { shell: false, detached: true }); }; registerEvent("openGame", openGame); diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index a8fc8b01..6a33ffaf 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -1,26 +1,26 @@ import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; -import { HydraApi, logger } from "@main/services"; +import { HydraApi } from "@main/services"; +import { gamesSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const removeGameFromLibrary = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - gameRepository.update( - { id: gameId }, - { isDeleted: true, executablePath: null } - ); + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); - removeRemoveGameFromLibrary(gameId).catch((err) => { - logger.error("removeRemoveGameFromLibrary", err); - }); -}; + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: true, + executablePath: null, + }); -const removeRemoveGameFromLibrary = async (gameId: number) => { - const game = await gameRepository.findOne({ where: { id: gameId } }); - - if (game?.remoteId) { - HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); + if (game?.remoteId) { + HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); + } } }; diff --git a/src/main/events/library/remove-game.ts b/src/main/events/library/remove-game.ts index 687366c5..a5310bc9 100644 --- a/src/main/events/library/remove-game.ts +++ b/src/main/events/library/remove-game.ts @@ -1,21 +1,14 @@ import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; +import { levelKeys, downloadsSublevel } from "@main/level"; +import { GameShop } from "@types"; const removeGame = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await gameRepository.update( - { - id: gameId, - }, - { - status: "removed", - downloadPath: null, - bytesDownloaded: 0, - progress: 0, - } - ); + const downloadKey = levelKeys.game(shop, objectId); + await downloadsSublevel.del(downloadKey); }; registerEvent("removeGame", removeGame); diff --git a/src/main/events/library/reset-game-achievements.ts b/src/main/events/library/reset-game-achievements.ts index 8d52a3a6..b3d2daa2 100644 --- a/src/main/events/library/reset-game-achievements.ts +++ b/src/main/events/library/reset-game-achievements.ts @@ -1,16 +1,22 @@ -import { gameAchievementRepository, gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import { findAchievementFiles } from "@main/services/achievements/find-achivement-files"; import fs from "fs"; import { achievementsLogger, HydraApi, WindowManager } from "@main/services"; import { getUnlockedAchievements } from "../user/get-unlocked-achievements"; +import { + gameAchievementsSublevel, + gamesSublevel, + levelKeys, +} from "@main/level"; +import type { GameShop } from "@types"; const resetGameAchievements = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { try { - const game = await gameRepository.findOne({ where: { id: gameId } }); + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); if (!game) return; @@ -23,28 +29,34 @@ const resetGameAchievements = async ( } } - await gameAchievementRepository.update( - { objectId: game.objectID }, - { - unlockedAchievements: null, - } - ); + const levelKey = levelKeys.game(game.shop, game.objectId); + + await gameAchievementsSublevel + .get(levelKey) + .then(async (gameAchievements) => { + if (gameAchievements) { + await gameAchievementsSublevel.put(levelKey, { + ...gameAchievements, + unlockedAchievements: [], + }); + } + }); await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then( () => achievementsLogger.log( - `Deleted achievements from ${game.remoteId} - ${game.objectID} - ${game.title}` + `Deleted achievements from ${game.remoteId} - ${game.objectId} - ${game.title}` ) ); const gameAchievements = await getUnlockedAchievements( - game.objectID, + game.objectId, game.shop, true ); WindowManager.mainWindow?.webContents.send( - `on-update-achievements-${game.objectID}-${game.shop}`, + `on-update-achievements-${game.objectId}-${game.shop}`, gameAchievements ); } catch (error) { diff --git a/src/main/events/library/select-game-wine-prefix.ts b/src/main/events/library/select-game-wine-prefix.ts index d9f01c08..c085dbad 100644 --- a/src/main/events/library/select-game-wine-prefix.ts +++ b/src/main/events/library/select-game-wine-prefix.ts @@ -1,13 +1,23 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; +import { levelKeys, gamesSublevel } from "@main/level"; +import type { GameShop } from "@types"; const selectGameWinePrefix = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, winePrefixPath: string | null ) => { - return gameRepository.update({ id }, { winePrefixPath: winePrefixPath }); + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + winePrefixPath: winePrefixPath, + }); }; registerEvent("selectGameWinePrefix", selectGameWinePrefix); diff --git a/src/main/events/library/update-executable-path.ts b/src/main/events/library/update-executable-path.ts index aee80771..e753706b 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -1,25 +1,27 @@ -import { gameRepository } from "@main/repository"; - import { registerEvent } from "../register-event"; import { parseExecutablePath } from "../helpers/parse-executable-path"; +import { gamesSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; const updateExecutablePath = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, executablePath: string | null ) => { const parsedPath = executablePath ? parseExecutablePath(executablePath) : null; - return gameRepository.update( - { - id, - }, - { - executablePath: parsedPath, - } - ); + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + executablePath: parsedPath, + }); }; registerEvent("updateExecutablePath", updateExecutablePath); diff --git a/src/main/events/library/update-launch-options.ts b/src/main/events/library/update-launch-options.ts index b33d031c..3e6c15cf 100644 --- a/src/main/events/library/update-launch-options.ts +++ b/src/main/events/library/update-launch-options.ts @@ -1,19 +1,23 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const updateLaunchOptions = async ( _event: Electron.IpcMainInvokeEvent, - id: number, + shop: GameShop, + objectId: string, launchOptions: string | null ) => { - return gameRepository.update( - { - id, - }, - { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (game) { + await gamesSublevel.put(gameKey, { + ...game, launchOptions: launchOptions?.trim() != "" ? launchOptions : null, - } - ); + }); + } }; registerEvent("updateLaunchOptions", updateLaunchOptions); diff --git a/src/main/events/library/verify-executable-path.ts b/src/main/events/library/verify-executable-path.ts index 22295ac7..a48a0d38 100644 --- a/src/main/events/library/verify-executable-path.ts +++ b/src/main/events/library/verify-executable-path.ts @@ -1,13 +1,17 @@ -import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { gamesSublevel } from "@main/level"; const verifyExecutablePathInUse = async ( _event: Electron.IpcMainInvokeEvent, executablePath: string ) => { - return gameRepository.findOne({ - where: { executablePath }, - }); + for await (const game of gamesSublevel.values()) { + if (game.executablePath === executablePath) { + return true; + } + } + + return false; }; registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse); diff --git a/src/main/events/misc/open-checkout.ts b/src/main/events/misc/open-checkout.ts index ba48f03b..76316a6e 100644 --- a/src/main/events/misc/open-checkout.ts +++ b/src/main/events/misc/open-checkout.ts @@ -1,17 +1,20 @@ import { shell } from "electron"; import { registerEvent } from "../register-event"; -import { userAuthRepository } from "@main/repository"; -import { HydraApi } from "@main/services"; +import { Crypto, HydraApi } from "@main/services"; +import { db, levelKeys } from "@main/level"; +import type { Auth } from "@types"; const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { - const userAuth = await userAuthRepository.findOne({ where: { id: 1 } }); + const auth = await db.get(levelKeys.auth, { + valueEncoding: "json", + }); - if (!userAuth) { + if (!auth) { return; } const paymentToken = await HydraApi.post("/auth/payment", { - refreshToken: userAuth.refreshToken, + refreshToken: Crypto.decrypt(auth.refreshToken), }).then((response) => response.accessToken); const params = new URLSearchParams({ diff --git a/src/main/events/notifications/publish-new-repacks-notification.ts b/src/main/events/notifications/publish-new-repacks-notification.ts index 5230c209..1f23eeb1 100644 --- a/src/main/events/notifications/publish-new-repacks-notification.ts +++ b/src/main/events/notifications/publish-new-repacks-notification.ts @@ -1,7 +1,8 @@ import { Notification } from "electron"; import { registerEvent } from "../register-event"; -import { userPreferencesRepository } from "@main/repository"; import { t } from "i18next"; +import { db, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; const publishNewRepacksNotification = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,9 +10,12 @@ const publishNewRepacksNotification = async ( ) => { if (newRepacksCount < 1) return; - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.repackUpdatesNotificationsEnabled) { new Notification({ diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 7b90e483..f5a04f0d 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -7,7 +7,7 @@ import { omit } from "lodash-es"; import axios from "axios"; import { fileTypeFromFile } from "file-type"; -const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { +export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { return HydraApi.patch("/profile", updateProfile); }; diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index fbdf2761..5d80337f 100644 --- a/src/main/events/torrenting/cancel-game-download.ts +++ b/src/main/events/torrenting/cancel-game-download.ts @@ -1,31 +1,19 @@ import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const cancelGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.cancelDownload(gameId); + const downloadKey = levelKeys.game(shop, objectId); - await transactionalEntityManager.getRepository(DownloadQueue).delete({ - game: { id: gameId }, - }); + await DownloadManager.cancelDownload(downloadKey); - await transactionalEntityManager.getRepository(Game).update( - { - id: gameId, - }, - { - status: "removed", - bytesDownloaded: 0, - progress: 0, - } - ); - }); + await downloadsSublevel.del(downloadKey); }; registerEvent("cancelGameDownload", cancelGameDownload); diff --git a/src/main/events/torrenting/pause-game-download.ts b/src/main/events/torrenting/pause-game-download.ts index 03bb2781..e3e14dec 100644 --- a/src/main/events/torrenting/pause-game-download.ts +++ b/src/main/events/torrenting/pause-game-download.ts @@ -1,24 +1,27 @@ import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { GameShop } from "@types"; +import { downloadsSublevel, levelKeys } from "@main/level"; const pauseGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.pauseDownload(); + const gameKey = levelKeys.game(shop, objectId); - await transactionalEntityManager.getRepository(DownloadQueue).delete({ - game: { id: gameId }, + const download = await downloadsSublevel.get(gameKey); + + if (download) { + await DownloadManager.pauseDownload(gameKey); + + await downloadsSublevel.put(gameKey, { + ...download, + status: "paused", + queued: false, }); - - await transactionalEntityManager - .getRepository(Game) - .update({ id: gameId }, { status: "paused" }); - }); + } }; registerEvent("pauseGameDownload", pauseGameDownload); diff --git a/src/main/events/torrenting/pause-game-seed.ts b/src/main/events/torrenting/pause-game-seed.ts index df2af756..b19da525 100644 --- a/src/main/events/torrenting/pause-game-seed.ts +++ b/src/main/events/torrenting/pause-game-seed.ts @@ -1,17 +1,24 @@ +import { downloadsSublevel, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; import { DownloadManager } from "@main/services"; -import { gameRepository } from "@main/repository"; +import type { GameShop } from "@types"; const pauseGameSeed = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - await gameRepository.update(gameId, { - status: "complete", + const downloadKey = levelKeys.game(shop, objectId); + const download = await downloadsSublevel.get(downloadKey); + + if (!download) return; + + await downloadsSublevel.put(downloadKey, { + ...download, shouldSeed: false, }); - await DownloadManager.pauseSeeding(gameId); + await DownloadManager.pauseSeeding(downloadKey); }; registerEvent("pauseGameSeed", pauseGameSeed); diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index c8c75545..48bb1c12 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -1,46 +1,37 @@ -import { Not } from "typeorm"; - import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; import { DownloadManager } from "@main/services"; -import { dataSource } from "@main/data-source"; -import { DownloadQueue, Game } from "@main/entity"; +import { downloadsSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; const resumeGameDownload = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { - id: gameId, - isDeleted: false, - }, - }); + const gameKey = levelKeys.game(shop, objectId); - if (!game) return; + const download = await downloadsSublevel.get(gameKey); - if (game.status === "paused") { - await dataSource.transaction(async (transactionalEntityManager) => { - await DownloadManager.pauseDownload(); + if (download?.status === "paused") { + await DownloadManager.pauseDownload(); - await transactionalEntityManager - .getRepository(Game) - .update({ status: "active", progress: Not(1) }, { status: "paused" }); + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", + }); + } + } - await DownloadManager.resumeDownload(game); + await DownloadManager.resumeDownload(download); - await transactionalEntityManager - .getRepository(DownloadQueue) - .delete({ game: { id: gameId } }); - - await transactionalEntityManager - .getRepository(DownloadQueue) - .insert({ game: { id: gameId } }); - - await transactionalEntityManager - .getRepository(Game) - .update({ id: gameId }, { status: "active" }); + await downloadsSublevel.put(gameKey, { + ...download, + status: "active", + timestamp: Date.now(), + queued: true, }); } }; diff --git a/src/main/events/torrenting/resume-game-seed.ts b/src/main/events/torrenting/resume-game-seed.ts index 9f79e53a..63bab952 100644 --- a/src/main/events/torrenting/resume-game-seed.ts +++ b/src/main/events/torrenting/resume-game-seed.ts @@ -1,29 +1,23 @@ +import { downloadsSublevel, levelKeys } from "@main/level"; import { registerEvent } from "../register-event"; -import { gameRepository } from "../../repository"; import { DownloadManager } from "@main/services"; -import { Downloader } from "@shared"; +import type { GameShop } from "@types"; const resumeGameSeed = async ( _event: Electron.IpcMainInvokeEvent, - gameId: number + shop: GameShop, + objectId: string ) => { - const game = await gameRepository.findOne({ - where: { - id: gameId, - isDeleted: false, - downloader: Downloader.Torrent, - progress: 1, - }, - }); + const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); - if (!game) return; + if (!download) return; - await gameRepository.update(gameId, { - status: "seeding", + await downloadsSublevel.put(levelKeys.game(shop, objectId), { + ...download, shouldSeed: true, }); - await DownloadManager.resumeSeeding(game); + await DownloadManager.resumeSeeding(download); }; registerEvent("resumeGameSeed", resumeGameSeed); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index de10b07d..fddda3f3 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,13 +1,11 @@ import { registerEvent } from "../register-event"; -import type { StartGameDownloadPayload } from "@types"; +import type { Download, StartGameDownloadPayload } from "@types"; import { DownloadManager, HydraApi } from "@main/services"; -import { Not } from "typeorm"; 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 } from "@main/entity"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -15,85 +13,85 @@ const startGameDownload = async ( ) => { const { objectId, title, shop, downloadPath, downloader, uri } = payload; - return dataSource.transaction(async (transactionalEntityManager) => { - const gameRepository = transactionalEntityManager.getRepository(Game); - const downloadQueueRepository = - transactionalEntityManager.getRepository(DownloadQueue); + const gameKey = levelKeys.game(shop, objectId); - const game = await gameRepository.findOne({ - where: { - objectID: objectId, - shop, - }, - }); + await DownloadManager.pauseDownload(); - await DownloadManager.pauseDownload(); - - await gameRepository.update( - { status: "active", progress: Not(1) }, - { status: "paused" } - ); - - if (game) { - await gameRepository.update( - { - id: game.id, - }, - { - status: "active", - progress: 0, - bytesDownloaded: 0, - downloadPath, - downloader, - uri, - isDeleted: false, - } - ); - } else { - const steamGame = await steamGamesWorker.run(Number(objectId), { - name: "getById", - }); - - const iconUrl = steamGame?.clientIcon - ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) - : null; - - await gameRepository.insert({ - title, - iconUrl, - objectID: objectId, - downloader, - shop, - status: "active", - downloadPath, - uri, + for await (const [key, value] of downloadsSublevel.iterator()) { + if (value.status === "active" && value.progress !== 1) { + await downloadsSublevel.put(key, { + ...value, + status: "paused", }); } + } - const updatedGame = await gameRepository.findOne({ - where: { - objectID: objectId, - }, + const game = await gamesSublevel.get(gameKey); + + /* Delete any previous download */ + await downloadsSublevel.del(gameKey); + + if (game?.isDeleted) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: false, + }); + } else { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", }); - await DownloadManager.cancelDownload(updatedGame!.id); - await DownloadManager.startDownload(updatedGame!); + const iconUrl = steamGame?.clientIcon + ? steamUrlBuilder.icon(objectId, steamGame.clientIcon) + : null; - await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); - await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); + await gamesSublevel.put(gameKey, { + title, + iconUrl, + objectId, + shop, + remoteId: null, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + isDeleted: false, + }); + } - await Promise.all([ - createGame(updatedGame!).catch(() => {}), - HydraApi.post( - "/games/download", - { - objectId: updatedGame!.objectID, - shop: updatedGame!.shop, - }, - { needsAuth: false } - ).catch(() => {}), - ]); - }); + await DownloadManager.cancelDownload(gameKey); + + const download: Download = { + shop, + objectId, + status: "active", + progress: 0, + bytesDownloaded: 0, + downloadPath, + downloader, + uri, + folderName: null, + fileSize: null, + shouldSeed: false, + timestamp: Date.now(), + queued: true, + }; + + await downloadsSublevel.put(gameKey, download); + + await DownloadManager.startDownload(download); + + const updatedGame = await gamesSublevel.get(gameKey); + + await Promise.all([ + createGame(updatedGame!).catch(() => {}), + HydraApi.post( + "/games/download", + { + objectId, + shop, + }, + { needsAuth: false } + ).catch(() => {}), + ]); }; registerEvent("startGameDownload", startGameDownload); diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index 2a2df254..19458496 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -1,9 +1,21 @@ -import { userPreferencesRepository } from "@main/repository"; import { registerEvent } from "../register-event"; +import { db, levelKeys } from "@main/level"; +import { Crypto } from "@main/services"; +import type { UserPreferences } from "@types"; const getUserPreferences = async () => - userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + db + .get(levelKeys.userPreferences, { + valueEncoding: "json", + }) + .then((userPreferences) => { + if (userPreferences.realDebridApiToken) { + userPreferences.realDebridApiToken = Crypto.decrypt( + userPreferences.realDebridApiToken + ); + } + + return userPreferences; + }); registerEvent("getUserPreferences", getUserPreferences); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index f45af519..caec78a1 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -1,23 +1,37 @@ -import { userPreferencesRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { UserPreferences } from "@types"; import i18next from "i18next"; +import { db, levelKeys } from "@main/level"; +import { patchUserProfile } from "../profile/update-profile"; const updateUserPreferences = async ( _event: Electron.IpcMainInvokeEvent, preferences: Partial ) => { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + if (preferences.language) { + await db.put(levelKeys.language, preferences.language, { + valueEncoding: "utf-8", + }); + i18next.changeLanguage(preferences.language); + patchUserProfile({ language: preferences.language }).catch(() => {}); } - return userPreferencesRepository.upsert( + await db.put( + levelKeys.userPreferences, { - id: 1, + ...userPreferences, ...preferences, }, - ["id"] + { + valueEncoding: "json", + } ); }; diff --git a/src/main/events/user/get-compared-unlocked-achievements.ts b/src/main/events/user/get-compared-unlocked-achievements.ts index 0b665212..33b37584 100644 --- a/src/main/events/user/get-compared-unlocked-achievements.ts +++ b/src/main/events/user/get-compared-unlocked-achievements.ts @@ -1,7 +1,8 @@ -import type { ComparedAchievements, GameShop } from "@types"; +import type { ComparedAchievements, GameShop, UserPreferences } from "@types"; import { registerEvent } from "../register-event"; -import { userPreferencesRepository } from "@main/repository"; + import { HydraApi } from "@main/services"; +import { db, levelKeys } from "@main/level"; const getComparedUnlockedAchievements = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,9 +10,12 @@ const getComparedUnlockedAchievements = async ( shop: GameShop, userId: string ) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); const showHiddenAchievementsDescription = userPreferences?.showHiddenAchievementsDescription || false; diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index ffa25399..9cb44423 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -1,23 +1,23 @@ -import type { GameShop, UnlockedAchievement, UserAchievement } from "@types"; +import type { GameShop, UserAchievement, UserPreferences } from "@types"; import { registerEvent } from "../register-event"; -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; export const getUnlockedAchievements = async ( objectId: string, shop: GameShop, useCachedData: boolean ): Promise => { - const cachedAchievements = await gameAchievementRepository.findOne({ - where: { objectId, shop }, - }); + const cachedAchievements = await gameAchievementsSublevel.get( + levelKeys.game(shop, objectId) + ); - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); const showHiddenAchievementsDescription = userPreferences?.showHiddenAchievementsDescription || false; @@ -25,12 +25,10 @@ export const getUnlockedAchievements = async ( const achievementsData = await getGameAchievementData( objectId, shop, - useCachedData ? cachedAchievements : null + useCachedData ); - const unlockedAchievements = JSON.parse( - cachedAchievements?.unlockedAchievements || "[]" - ) as UnlockedAchievement[]; + const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? []; return achievementsData .map((achievementData) => { diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts index 9a6f156c..aefc7052 100644 --- a/src/main/events/user/get-user-friends.ts +++ b/src/main/events/user/get-user-friends.ts @@ -1,16 +1,19 @@ -import { userAuthRepository } from "@main/repository"; +import { db } from "@main/level"; import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; -import type { UserFriends } from "@types"; +import type { User, UserFriends } from "@types"; +import { levelKeys } from "@main/level/sublevels"; export const getUserFriends = async ( userId: string, take: number, skip: number ): Promise => { - const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + const user = await db.get(levelKeys.user, { + valueEncoding: "json", + }); - if (loggedUser?.userId === userId) { + if (user?.id === userId) { return HydraApi.get(`/profile/friends`, { take, skip }); } diff --git a/src/main/index.ts b/src/main/index.ts index ca49a9fb..2a18fa31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,16 +3,13 @@ import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; import url from "node:url"; -import fs from "node:fs"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { logger, WindowManager } from "@main/services"; -import { dataSource } from "@main/data-source"; import resources from "@locales"; -import { userPreferencesRepository } from "@main/repository"; -import { knexClient, migrationConfig } from "./knex-client"; -import { databaseDirectory } from "./constants"; import { PythonRPC } from "./services/python-rpc"; import { Aria2 } from "./services/aria2"; +import { db, levelKeys } from "./level"; +import { loadState } from "./main"; const { autoUpdater } = updater; @@ -50,21 +47,6 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL); } -const runMigrations = async () => { - if (!fs.existsSync(databaseDirectory)) { - fs.mkdirSync(databaseDirectory, { recursive: true }); - } - - await knexClient.migrate.list(migrationConfig).then((result) => { - logger.log( - "Migrations to run:", - result[1].map((migration) => migration.name) - ); - }); - - await knexClient.migrate.latest(migrationConfig); -}; - // 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. @@ -76,31 +58,19 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); - await runMigrations() - .then(() => { - logger.log("Migrations executed successfully"); - }) - .catch((err) => { - logger.log("Migrations failed to run:", err); - }); + await loadState(); - await dataSource.initialize(); - - await import("./main"); - - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, + const language = await db.get(levelKeys.language, { + valueEncoding: "utf-8", }); - if (userPreferences?.language) { - i18n.changeLanguage(userPreferences.language); - } + if (language) i18n.changeLanguage(language); if (!process.argv.includes("--hidden")) { WindowManager.createMainWindow(); } - WindowManager.createSystemTray(userPreferences?.language || "en"); + WindowManager.createSystemTray(language || "en"); }); app.on("browser-window-created", (_, window) => { diff --git a/src/main/knex-client.ts b/src/main/knex-client.ts index c816c7c7..57982332 100644 --- a/src/main/knex-client.ts +++ b/src/main/knex-client.ts @@ -1,55 +1,6 @@ -import knex, { Knex } from "knex"; +import knex from "knex"; import { databasePath } from "./constants"; -import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3"; -import { RepackUris } from "./migrations/20240830143906_RepackUris"; -import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language"; -import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris"; import { app } from "electron"; -import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns"; -import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement"; -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"; -import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column"; -import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column"; -import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum"; -import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download"; -import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column "; -import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game"; -import { AddTorBoxApiToken } from "./migrations/20250111182229_add_torbox_api_token_column"; - -export type HydraMigration = Knex.Migration & { name: string }; - -class MigrationSource implements Knex.MigrationSource { - getMigrations(): Promise { - return Promise.resolve([ - Hydra2_0_3, - RepackUris, - UpdateUserLanguage, - EnsureRepackUris, - FixMissingColumns, - CreateGameAchievement, - AddAchievementNotificationPreference, - CreateUserSubscription, - AddBackgroundImageUrl, - AddWinePrefixToGame, - AddStartMinimizedColumn, - AddDisableNsfwAlertColumn, - AddShouldSeedColumn, - AddSeedAfterDownloadColumn, - AddHiddenAchievementDescriptionColumn, - AddLaunchOptionsColumnToGame, - AddTorBoxApiToken, - ]); - } - getMigrationName(migration: HydraMigration): string { - return migration.name; - } - getMigration(migration: HydraMigration): Promise { - return Promise.resolve(migration); - } -} export const knexClient = knex({ debug: !app.isPackaged, @@ -58,7 +9,3 @@ export const knexClient = knex({ filename: databasePath, }, }); - -export const migrationConfig: Knex.MigratorConfig = { - migrationSource: new MigrationSource(), -}; diff --git a/src/main/level/index.ts b/src/main/level/index.ts new file mode 100644 index 00000000..90a34be3 --- /dev/null +++ b/src/main/level/index.ts @@ -0,0 +1,3 @@ +export { db } from "./level"; + +export * from "./sublevels"; diff --git a/src/main/level/level.ts b/src/main/level/level.ts new file mode 100644 index 00000000..9819efad --- /dev/null +++ b/src/main/level/level.ts @@ -0,0 +1,6 @@ +import { levelDatabasePath } from "@main/constants"; +import { ClassicLevel } from "classic-level"; + +export const db = new ClassicLevel(levelDatabasePath, { + valueEncoding: "json", +}); diff --git a/src/main/level/sublevels/downloads.ts b/src/main/level/sublevels/downloads.ts new file mode 100644 index 00000000..23030670 --- /dev/null +++ b/src/main/level/sublevels/downloads.ts @@ -0,0 +1,11 @@ +import type { Download } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const downloadsSublevel = db.sublevel( + levelKeys.downloads, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/game-achievements.ts b/src/main/level/sublevels/game-achievements.ts new file mode 100644 index 00000000..4b1fa0c8 --- /dev/null +++ b/src/main/level/sublevels/game-achievements.ts @@ -0,0 +1,11 @@ +import type { GameAchievement } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gameAchievementsSublevel = db.sublevel( + levelKeys.gameAchievements, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/game-shop-cache.ts b/src/main/level/sublevels/game-shop-cache.ts new file mode 100644 index 00000000..8187e5c0 --- /dev/null +++ b/src/main/level/sublevels/game-shop-cache.ts @@ -0,0 +1,11 @@ +import type { ShopDetails } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gamesShopCacheSublevel = db.sublevel( + levelKeys.gameShopCache, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/games.ts b/src/main/level/sublevels/games.ts new file mode 100644 index 00000000..ce7492f1 --- /dev/null +++ b/src/main/level/sublevels/games.ts @@ -0,0 +1,8 @@ +import type { Game } from "@types"; + +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const gamesSublevel = db.sublevel(levelKeys.games, { + valueEncoding: "json", +}); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts new file mode 100644 index 00000000..a96a464c --- /dev/null +++ b/src/main/level/sublevels/index.ts @@ -0,0 +1,6 @@ +export * from "./downloads"; +export * from "./games"; +export * from "./game-shop-cache"; +export * from "./game-achievements"; + +export * from "./keys"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts new file mode 100644 index 00000000..53eae44b --- /dev/null +++ b/src/main/level/sublevels/keys.ts @@ -0,0 +1,16 @@ +import type { GameShop } from "@types"; + +export const levelKeys = { + games: "games", + game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`, + user: "user", + auth: "auth", + gameShopCache: "gameShopCache", + gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => + `${shop}:${objectId}:${language}`, + gameAchievements: "gameAchievements", + downloads: "downloads", + userPreferences: "userPreferences", + language: "language", + sqliteMigrationDone: "sqliteMigrationDone", +}; diff --git a/src/main/main.ts b/src/main/main.ts index d27f0cbd..d5d23cdb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,29 +1,45 @@ -import { DownloadManager, Ludusavi, startMainLoop } from "./services"; import { - downloadQueueRepository, - gameRepository, - userPreferencesRepository, -} from "./repository"; -import { UserPreferences } from "./entity"; + Crypto, + DownloadManager, + logger, + Ludusavi, + startMainLoop, +} from "./services"; import { RealDebridClient } from "./services/download/real-debrid"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; import { Aria2 } from "./services/aria2"; +import { downloadsSublevel } from "./level/sublevels/downloads"; +import { sortBy } from "lodash-es"; import { Downloader } from "@shared"; -import { IsNull, Not } from "typeorm"; -import { TorBoxClient } from "./services/download/torbox"; +import { + gameAchievementsSublevel, + gamesSublevel, + levelKeys, + db, +} from "./level"; +import { Auth, User, type UserPreferences } from "@types"; +import { knexClient } from "./knex-client"; -const loadState = async (userPreferences: UserPreferences | null) => { - import("./events"); +export const loadState = async () => { + const userPreferences = await migrateFromSqlite().then(async () => { + await db.put(levelKeys.sqliteMigrationDone, true, { + valueEncoding: "json", + }); + + return db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }); + }); + + await import("./events"); Aria2.spawn(); if (userPreferences?.realDebridApiToken) { - RealDebridClient.authorize(userPreferences.realDebridApiToken); - } - - if (userPreferences?.torBoxApiToken) { - TorBoxClient.authorize(userPreferences?.torBoxApiToken); + RealDebridClient.authorize( + Crypto.decrypt(userPreferences.realDebridApiToken) + ); } Ludusavi.addManifestToLudusaviConfig(); @@ -32,33 +48,157 @@ const loadState = async (userPreferences: UserPreferences | null) => { uploadGamesBatch(); }); - const [nextQueueItem] = await downloadQueueRepository.find({ - order: { - id: "DESC", - }, - relations: { - game: true, - }, - }); + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => { + return sortBy( + games.filter((game) => game.queued), + "timestamp", + "DESC" + ); + }); - const seedList = await gameRepository.find({ - where: { - shouldSeed: true, - downloader: Downloader.Torrent, - progress: 1, - uri: Not(IsNull()), - }, - }); + const [nextItemOnQueue] = downloads; - await DownloadManager.startRPC(nextQueueItem?.game, seedList); + const downloadsToSeed = downloads.filter( + (download) => + download.shouldSeed && + download.downloader === Downloader.Torrent && + download.progress === 1 && + download.uri !== null + ); + + await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); startMainLoop(); }; -userPreferencesRepository - .findOne({ - where: { id: 1 }, - }) - .then((userPreferences) => { - loadState(userPreferences); - }); +const migrateFromSqlite = async () => { + const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone); + + if (sqliteMigrationDone) { + return; + } + + const migrateGames = knexClient("game") + .select("*") + .then((games) => { + return gamesSublevel.batch( + games.map((game) => ({ + type: "put", + key: levelKeys.game(game.shop, game.objectID), + value: { + objectId: game.objectID, + shop: game.shop, + title: game.title, + iconUrl: game.iconUrl, + playTimeInMilliseconds: game.playTimeInMilliseconds, + lastTimePlayed: game.lastTimePlayed, + remoteId: game.remoteId, + winePrefixPath: game.winePrefixPath, + launchOptions: game.launchOptions, + executablePath: game.executablePath, + isDeleted: game.isDeleted === 1, + }, + })) + ); + }) + .then(() => { + logger.info("Games migrated successfully"); + }); + + const migrateUserPreferences = knexClient("user_preferences") + .select("*") + .then(async (userPreferences) => { + if (userPreferences.length > 0) { + const { realDebridApiToken, ...rest } = userPreferences[0]; + + await db.put(levelKeys.userPreferences, { + ...rest, + realDebridApiToken: realDebridApiToken + ? Crypto.encrypt(realDebridApiToken) + : null, + preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, + runAtStartup: rest.runAtStartup === 1, + startMinimized: rest.startMinimized === 1, + disableNsfwAlert: rest.disableNsfwAlert === 1, + seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, + showHiddenAchievementsDescription: + rest.showHiddenAchievementsDescription === 1, + downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 1, + repackUpdatesNotificationsEnabled: + rest.repackUpdatesNotificationsEnabled === 1, + achievementNotificationsEnabled: + rest.achievementNotificationsEnabled === 1, + }); + + if (rest.language) { + await db.put(levelKeys.language, rest.language); + } + } + }) + .then(() => { + logger.info("User preferences migrated successfully"); + }); + + const migrateAchievements = knexClient("game_achievement") + .select("*") + .then((achievements) => { + return gameAchievementsSublevel.batch( + achievements.map((achievement) => ({ + type: "put", + key: levelKeys.game(achievement.shop, achievement.objectId), + value: { + achievements: JSON.parse(achievement.achievements), + unlockedAchievements: JSON.parse(achievement.unlockedAchievements), + }, + })) + ); + }) + .then(() => { + logger.info("Achievements migrated successfully"); + }); + + const migrateUser = knexClient("user_auth") + .select("*") + .then(async (users) => { + if (users.length > 0) { + await db.put( + levelKeys.user, + { + id: users[0].userId, + displayName: users[0].displayName, + profileImageUrl: users[0].profileImageUrl, + backgroundImageUrl: users[0].backgroundImageUrl, + subscription: users[0].subscription, + }, + { + valueEncoding: "json", + } + ); + + await db.put( + levelKeys.auth, + { + accessToken: Crypto.encrypt(users[0].accessToken), + refreshToken: Crypto.encrypt(users[0].refreshToken), + tokenExpirationTimestamp: users[0].tokenExpirationTimestamp, + }, + { + valueEncoding: "json", + } + ); + } + }) + .then(() => { + logger.info("User data migrated successfully"); + }); + + return Promise.allSettled([ + migrateGames, + migrateUserPreferences, + migrateAchievements, + migrateUser, + ]); +}; diff --git a/src/main/migrations/20240830143811_Hydra_2_0_3.ts b/src/main/migrations/20240830143811_Hydra_2_0_3.ts deleted file mode 100644 index 6013f714..00000000 --- a/src/main/migrations/20240830143811_Hydra_2_0_3.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const Hydra2_0_3: HydraMigration = { - name: "Hydra_2_0_3", - up: async (knex: Knex) => { - const timestamp = new Date().getTime(); - - await knex.schema.hasTable("migrations").then(async (exists) => { - if (exists) { - await knex.schema.dropTable("migrations"); - } - }); - - await knex.schema.hasTable("download_source").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("download_source", (table) => { - table.increments("id").primary(); - table - .text("url") - .unique({ indexName: "download_source_url_unique_" + timestamp }); - table.text("name").notNullable(); - table.text("etag"); - table.integer("downloadCount").notNullable().defaultTo(0); - table.text("status").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("repack").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("repack", (table) => { - table.increments("id").primary(); - table - .text("title") - .notNullable() - .unique({ indexName: "repack_title_unique_" + timestamp }); - table - .text("magnet") - .notNullable() - .unique({ indexName: "repack_magnet_unique_" + timestamp }); - table.integer("page"); - table.text("repacker").notNullable(); - table.text("fileSize").notNullable(); - table.datetime("uploadDate").notNullable(); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - table - .integer("downloadSourceId") - .references("download_source.id") - .onDelete("CASCADE"); - }); - } - }); - - await knex.schema.hasTable("game").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("game", (table) => { - table.increments("id").primary(); - table - .text("objectID") - .notNullable() - .unique({ indexName: "game_objectID_unique_" + timestamp }); - table - .text("remoteId") - .unique({ indexName: "game_remoteId_unique_" + timestamp }); - table.text("title").notNullable(); - table.text("iconUrl"); - table.text("folderName"); - table.text("downloadPath"); - table.text("executablePath"); - table.integer("playTimeInMilliseconds").notNullable().defaultTo(0); - table.text("shop").notNullable(); - table.text("status"); - table.integer("downloader").notNullable().defaultTo(1); - table.float("progress").notNullable().defaultTo(0); - table.integer("bytesDownloaded").notNullable().defaultTo(0); - table.datetime("lastTimePlayed"); - table.float("fileSize").notNullable().defaultTo(0); - table.text("uri"); - table.boolean("isDeleted").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - table - .integer("repackId") - .references("repack.id") - .unique("repack_repackId_unique_" + timestamp); - }); - } - }); - - await knex.schema.hasTable("user_preferences").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("user_preferences", (table) => { - table.increments("id").primary(); - table.text("downloadsPath"); - table.text("language").notNullable().defaultTo("en"); - table.text("realDebridApiToken"); - table - .boolean("downloadNotificationsEnabled") - .notNullable() - .defaultTo(0); - table - .boolean("repackUpdatesNotificationsEnabled") - .notNullable() - .defaultTo(0); - table.boolean("preferQuitInsteadOfHiding").notNullable().defaultTo(0); - table.boolean("runAtStartup").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("game_shop_cache").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("game_shop_cache", (table) => { - table.text("objectID").primary().notNullable(); - table.text("shop").notNullable(); - table.text("serializedData"); - table.text("howLongToBeatSerializedData"); - table.text("language"); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("download_queue").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("download_queue", (table) => { - table.increments("id").primary(); - table - .integer("gameId") - .references("game.id") - .unique("download_queue_gameId_unique_" + timestamp); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - - await knex.schema.hasTable("user_auth").then(async (exists) => { - if (!exists) { - await knex.schema.createTable("user_auth", (table) => { - table.increments("id").primary(); - table.text("userId").notNullable().defaultTo(""); - table.text("displayName").notNullable().defaultTo(""); - table.text("profileImageUrl"); - table.text("accessToken").notNullable().defaultTo(""); - table.text("refreshToken").notNullable().defaultTo(""); - table.integer("tokenExpirationTimestamp").notNullable().defaultTo(0); - table.datetime("createdAt").notNullable().defaultTo(knex.fn.now()); - table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now()); - }); - } - }); - }, - - down: async (knex: Knex) => { - await knex.schema.dropTableIfExists("game"); - await knex.schema.dropTableIfExists("repack"); - await knex.schema.dropTableIfExists("download_queue"); - await knex.schema.dropTableIfExists("user_auth"); - await knex.schema.dropTableIfExists("game_shop_cache"); - await knex.schema.dropTableIfExists("user_preferences"); - await knex.schema.dropTableIfExists("download_source"); - }, -}; diff --git a/src/main/migrations/20240830143906_RepackUris.ts b/src/main/migrations/20240830143906_RepackUris.ts deleted file mode 100644 index 18bb9a59..00000000 --- a/src/main/migrations/20240830143906_RepackUris.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const RepackUris: HydraMigration = { - name: "RepackUris", - up: async (knex: Knex) => { - await knex.schema.alterTable("repack", (table) => { - table.text("uris").notNullable().defaultTo("[]"); - }); - }, - - down: async (knex: Knex) => { - await knex.schema.alterTable("repack", (table) => { - table.integer("page"); - table.dropColumn("uris"); - }); - }, -}; diff --git a/src/main/migrations/20240913213944_update_user_language.ts b/src/main/migrations/20240913213944_update_user_language.ts deleted file mode 100644 index 3297eb0d..00000000 --- a/src/main/migrations/20240913213944_update_user_language.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const UpdateUserLanguage: HydraMigration = { - name: "UpdateUserLanguage", - up: async (knex: Knex) => { - await knex("user_preferences") - .update("language", "pt-BR") - .where("language", "pt"); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240915035339_ensure_repack_uris.ts b/src/main/migrations/20240915035339_ensure_repack_uris.ts deleted file mode 100644 index 64fbcd2e..00000000 --- a/src/main/migrations/20240915035339_ensure_repack_uris.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const EnsureRepackUris: HydraMigration = { - name: "EnsureRepackUris", - up: async (knex: Knex) => { - await knex.schema.hasColumn("repack", "uris").then(async (exists) => { - if (!exists) { - await knex.schema.table("repack", (table) => { - table.text("uris").notNullable().defaultTo("[]"); - }); - } - }); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240918001920_FixMissingColumns.ts b/src/main/migrations/20240918001920_FixMissingColumns.ts deleted file mode 100644 index d23662ed..00000000 --- a/src/main/migrations/20240918001920_FixMissingColumns.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const FixMissingColumns: HydraMigration = { - name: "FixMissingColumns", - up: async (knex: Knex) => { - const timestamp = new Date().getTime(); - await knex.schema - .hasColumn("repack", "downloadSourceId") - .then(async (exists) => { - if (!exists) { - await knex.schema.table("repack", (table) => { - table - .integer("downloadSourceId") - .references("download_source.id") - .onDelete("CASCADE"); - }); - } - }); - - await knex.schema.hasColumn("game", "remoteId").then(async (exists) => { - if (!exists) { - await knex.schema.table("game", (table) => { - table - .text("remoteId") - .unique({ indexName: "game_remoteId_unique_" + timestamp }); - }); - } - }); - - await knex.schema.hasColumn("game", "uri").then(async (exists) => { - if (!exists) { - await knex.schema.table("game", (table) => { - table.text("uri"); - }); - } - }); - }, - - down: async (_knex: Knex) => {}, -}; diff --git a/src/main/migrations/20240919030940_create_game_achievement.ts b/src/main/migrations/20240919030940_create_game_achievement.ts deleted file mode 100644 index 791eeb29..00000000 --- a/src/main/migrations/20240919030940_create_game_achievement.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const CreateGameAchievement: HydraMigration = { - name: "CreateGameAchievement", - up: (knex: Knex) => { - return knex.schema.createTable("game_achievement", (table) => { - table.increments("id").primary(); - table.text("objectId").notNullable(); - table.text("shop").notNullable(); - table.text("achievements"); - table.text("unlockedAchievements"); - table.unique(["objectId", "shop"]); - }); - }, - - down: (knex: Knex) => { - return knex.schema.dropTable("game_achievement"); - }, -}; diff --git a/src/main/migrations/20241013012900_add_achievement_notification_preference.ts b/src/main/migrations/20241013012900_add_achievement_notification_preference.ts deleted file mode 100644 index a4f48265..00000000 --- a/src/main/migrations/20241013012900_add_achievement_notification_preference.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddAchievementNotificationPreference: HydraMigration = { - name: "AddAchievementNotificationPreference", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("achievementNotificationsEnabled").defaultTo(true); - }); - }, - - down: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("achievementNotificationsEnabled"); - }); - }, -}; diff --git a/src/main/migrations/20241015235142_create_user_subscription.ts b/src/main/migrations/20241015235142_create_user_subscription.ts deleted file mode 100644 index 5f9ecab1..00000000 --- a/src/main/migrations/20241015235142_create_user_subscription.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const CreateUserSubscription: HydraMigration = { - name: "CreateUserSubscription", - up: async (knex: Knex) => { - return knex.schema.createTable("user_subscription", (table) => { - table.increments("id").primary(); - table.string("subscriptionId").defaultTo(""); - table - .text("userId") - .notNullable() - .references("user_auth.id") - .onDelete("CASCADE"); - table.string("status").defaultTo(""); - table.string("planId").defaultTo(""); - table.string("planName").defaultTo(""); - table.dateTime("expiresAt").nullable(); - table.dateTime("createdAt").defaultTo(knex.fn.now()); - table.dateTime("updatedAt").defaultTo(knex.fn.now()); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.dropTable("user_subscription"); - }, -}; diff --git a/src/main/migrations/20241016100249_add_background_image_url.ts b/src/main/migrations/20241016100249_add_background_image_url.ts deleted file mode 100644 index b377c650..00000000 --- a/src/main/migrations/20241016100249_add_background_image_url.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddBackgroundImageUrl: HydraMigration = { - name: "AddBackgroundImageUrl", - up: (knex: Knex) => { - return knex.schema.alterTable("user_auth", (table) => { - return table.text("backgroundImageUrl").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_auth", (table) => { - return table.dropColumn("backgroundImageUrl"); - }); - }, -}; diff --git a/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts b/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts deleted file mode 100644 index 517f6fb5..00000000 --- a/src/main/migrations/20241019081648_add_wine_prefix_to_game.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddWinePrefixToGame: HydraMigration = { - name: "AddWinePrefixToGame", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.text("winePrefixPath").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("winePrefixPath"); - }); - }, -}; diff --git a/src/main/migrations/20241030171454_add_start_minimized_column.ts b/src/main/migrations/20241030171454_add_start_minimized_column.ts deleted file mode 100644 index 69ede189..00000000 --- a/src/main/migrations/20241030171454_add_start_minimized_column.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddStartMinimizedColumn: HydraMigration = { - name: "AddStartMinimizedColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("startMinimized").notNullable().defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("startMinimized"); - }); - }, -}; diff --git a/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts b/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts deleted file mode 100644 index a248dd2b..00000000 --- a/src/main/migrations/20241106053733_add_disable_nsfw_alert_column.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddDisableNsfwAlertColumn: HydraMigration = { - name: "AddDisableNsfwAlertColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.boolean("disableNsfwAlert").notNullable().defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("disableNsfwAlert"); - }); - }, -}; diff --git a/src/main/migrations/20241108200154_add_should_seed_colum.ts b/src/main/migrations/20241108200154_add_should_seed_colum.ts deleted file mode 100644 index 7e90a3b1..00000000 --- a/src/main/migrations/20241108200154_add_should_seed_colum.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddShouldSeedColumn: HydraMigration = { - name: "AddShouldSeedColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.boolean("shouldSeed").notNullable().defaultTo(true); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("shouldSeed"); - }); - }, -}; diff --git a/src/main/migrations/20241108201806_add_seed_after_download.ts b/src/main/migrations/20241108201806_add_seed_after_download.ts deleted file mode 100644 index 75b94577..00000000 --- a/src/main/migrations/20241108201806_add_seed_after_download.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddSeedAfterDownloadColumn: HydraMigration = { - name: "AddSeedAfterDownloadColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table - .boolean("seedAfterDownloadComplete") - .notNullable() - .defaultTo(true); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("seedAfterDownloadComplete"); - }); - }, -}; diff --git a/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts b/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts deleted file mode 100644 index 36771c43..00000000 --- a/src/main/migrations/20241216140633_add_hidden_achievement_description_column .ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddHiddenAchievementDescriptionColumn: HydraMigration = { - name: "AddHiddenAchievementDescriptionColumn", - up: (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table - .boolean("showHiddenAchievementsDescription") - .notNullable() - .defaultTo(0); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("user_preferences", (table) => { - return table.dropColumn("showHiddenAchievementsDescription"); - }); - }, -}; diff --git a/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts b/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts deleted file mode 100644 index 417eeb63..00000000 --- a/src/main/migrations/20241226044022_add_launch_options_column_to_game.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const AddLaunchOptionsColumnToGame: HydraMigration = { - name: "AddLaunchOptionsColumnToGame", - up: (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.string("launchOptions").nullable(); - }); - }, - - down: async (knex: Knex) => { - return knex.schema.alterTable("game", (table) => { - return table.dropColumn("launchOptions"); - }); - }, -}; diff --git a/src/main/migrations/migration.stub b/src/main/migrations/migration.stub deleted file mode 100644 index 299b3fc2..00000000 --- a/src/main/migrations/migration.stub +++ /dev/null @@ -1,11 +0,0 @@ -import type { HydraMigration } from "@main/knex-client"; -import type { Knex } from "knex"; - -export const MigrationName: HydraMigration = { - name: "MigrationName", - up: (knex: Knex) => { - return knex.schema.createTable("table_name", async (table) => {}); - }, - - down: async (knex: Knex) => {}, -}; diff --git a/src/main/repository.ts b/src/main/repository.ts deleted file mode 100644 index e0c4204e..00000000 --- a/src/main/repository.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { dataSource } from "./data-source"; -import { - DownloadQueue, - Game, - GameShopCache, - UserPreferences, - UserAuth, - GameAchievement, - UserSubscription, -} from "@main/entity"; - -export const gameRepository = dataSource.getRepository(Game); - -export const userPreferencesRepository = - dataSource.getRepository(UserPreferences); - -export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); - -export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); - -export const userAuthRepository = dataSource.getRepository(UserAuth); - -export const userSubscriptionRepository = - dataSource.getRepository(UserSubscription); - -export const gameAchievementRepository = - dataSource.getRepository(GameAchievement); diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index 6a1eb11c..8b076d9e 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -1,6 +1,4 @@ -import { gameRepository } from "@main/repository"; import { parseAchievementFile } from "./parse-achievement-file"; -import { Game } from "@main/entity"; import { mergeAchievements } from "./merge-achievements"; import fs, { readdirSync } from "node:fs"; import { @@ -9,21 +7,20 @@ import { findAllAchievementFiles, getAlternativeObjectIds, } from "./find-achivement-files"; -import type { AchievementFile, UnlockedAchievement } from "@types"; +import type { AchievementFile, Game, UnlockedAchievement } from "@types"; import { achievementsLogger } from "../logger"; import { Cracker } from "@shared"; -import { IsNull, Not } from "typeorm"; import { publishCombinedNewAchievementNotification } from "../notifications"; +import { gamesSublevel } from "@main/level"; const fileStats: Map = new Map(); const fltFiles: Map> = new Map(); const watchAchievementsWindows = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); if (games.length === 0) return; @@ -32,7 +29,7 @@ const watchAchievementsWindows = async () => { for (const game of games) { const gameAchievementFiles: AchievementFile[] = []; - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); gameAchievementFiles.push( @@ -47,12 +44,12 @@ const watchAchievementsWindows = async () => { }; const watchAchievementsWithWine = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - winePrefixPath: Not(IsNull()), - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => + games.filter((game) => !game.isDeleted && game.winePrefixPath) + ); for (const game of games) { const gameAchievementFiles = findAchievementFiles(game); @@ -144,7 +141,7 @@ const processAchievementFileDiff = async ( export class AchievementWatcherManager { private static hasFinishedMergingWithRemote = false; - public static watchAchievements = () => { + public static watchAchievements() { if (!this.hasFinishedMergingWithRemote) return; if (process.platform === "win32") { @@ -152,12 +149,12 @@ export class AchievementWatcherManager { } return watchAchievementsWithWine(); - }; + } - private static preProcessGameAchievementFiles = ( + private static preProcessGameAchievementFiles( game: Game, gameAchievementFiles: AchievementFile[] - ) => { + ) { const unlockedAchievements: UnlockedAchievement[] = []; for (const achievementFile of gameAchievementFiles) { const parsedAchievements = parseAchievementFile( @@ -185,14 +182,13 @@ export class AchievementWatcherManager { } return mergeAchievements(game, unlockedAchievements, false); - }; + } private static preSearchAchievementsWindows = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); const gameAchievementFilesMap = findAllAchievementFiles(); @@ -200,7 +196,7 @@ export class AchievementWatcherManager { games.map((game) => { const gameAchievementFiles: AchievementFile[] = []; - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { gameAchievementFiles.push( ...(gameAchievementFilesMap.get(objectId) || []) ); @@ -216,11 +212,10 @@ export class AchievementWatcherManager { }; private static preSearchAchievementsWithWine = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => games.filter((game) => !game.isDeleted)); return Promise.all( games.map((game) => { @@ -235,7 +230,7 @@ export class AchievementWatcherManager { ); }; - public static preSearchAchievements = async () => { + public static async preSearchAchievements() { try { const newAchievementsCount = process.platform === "win32" @@ -261,5 +256,5 @@ export class AchievementWatcherManager { } this.hasFinishedMergingWithRemote = true; - }; + } } diff --git a/src/main/services/achievements/find-achivement-files.ts b/src/main/services/achievements/find-achivement-files.ts index 4fc6a4cd..7c0660cc 100644 --- a/src/main/services/achievements/find-achivement-files.ts +++ b/src/main/services/achievements/find-achivement-files.ts @@ -1,9 +1,8 @@ import path from "node:path"; import fs from "node:fs"; import { app } from "electron"; -import type { AchievementFile } from "@types"; +import type { Game, AchievementFile } from "@types"; import { Cracker } from "@shared"; -import { Game } from "@main/entity"; import { achievementsLogger } from "../logger"; const getAppDataPath = () => { @@ -254,7 +253,7 @@ export const findAchievementFiles = (game: Game) => { for (const cracker of crackers) { for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { - for (const objectId of getAlternativeObjectIds(game.objectID)) { + for (const objectId of getAlternativeObjectIds(game.objectId)) { const filePath = path.join( game.winePrefixPath ?? "", folderPath, diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index daac7e11..0d0c58f9 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -1,40 +1,37 @@ -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; import { HydraApi } from "../hydra-api"; -import type { AchievementData, GameShop } from "@types"; +import type { GameShop, SteamAchievement } from "@types"; import { UserNotLoggedInError } from "@shared"; import { logger } from "../logger"; -import { GameAchievement } from "@main/entity"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; export const getGameAchievementData = async ( objectId: string, shop: GameShop, - cachedAchievements: GameAchievement | null + useCachedData: boolean ) => { - if (cachedAchievements && cachedAchievements.achievements) { - return JSON.parse(cachedAchievements.achievements) as AchievementData[]; - } + const cachedAchievements = await gameAchievementsSublevel.get( + levelKeys.game(shop, objectId) + ); - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + if (cachedAchievements && useCachedData) + return cachedAchievements.achievements; - return HydraApi.get("/games/achievements", { + const language = await db + .get(levelKeys.language, { + valueEncoding: "utf-8", + }) + .then((language) => language || "en"); + + return HydraApi.get("/games/achievements", { shop, objectId, - language: userPreferences?.language || "en", + language, }) - .then((achievements) => { - gameAchievementRepository.upsert( - { - objectId, - shop, - achievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ); + .then(async (achievements) => { + await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { + unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], + achievements, + }); return achievements; }) @@ -42,15 +39,9 @@ export const getGameAchievementData = async ( if (err instanceof UserNotLoggedInError) { throw err; } - logger.error("Failed to get game achievements", err); - return gameAchievementRepository - .findOne({ - where: { objectId, shop }, - }) - .then((gameAchievements) => { - return JSON.parse( - gameAchievements?.achievements || "[]" - ) as AchievementData[]; - }); + + logger.error("Failed to get game achievements for", objectId, err); + + return []; }); }; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index dd8c877d..7e6ebf0a 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -1,42 +1,45 @@ -import { - gameAchievementRepository, - userPreferencesRepository, -} from "@main/repository"; -import type { AchievementData, GameShop, UnlockedAchievement } from "@types"; +import type { + Game, + GameShop, + UnlockedAchievement, + UserPreferences, +} from "@types"; import { WindowManager } from "../window-manager"; import { HydraApi } from "../hydra-api"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; -import { Game } from "@main/entity"; import { publishNewAchievementNotification } from "../notifications"; import { SubscriptionRequiredError } from "@shared"; import { achievementsLogger } from "../logger"; +import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; const saveAchievementsOnLocal = async ( objectId: string, shop: GameShop, - achievements: UnlockedAchievement[], + unlockedAchievements: UnlockedAchievement[], sendUpdateEvent: boolean ) => { - return gameAchievementRepository - .upsert( - { - objectId, - shop, - unlockedAchievements: JSON.stringify(achievements), - }, - ["objectId", "shop"] - ) - .then(() => { - if (!sendUpdateEvent) return; + const levelKey = levelKeys.game(shop, objectId); - return getUnlockedAchievements(objectId, shop, true) - .then((achievements) => { - WindowManager.mainWindow?.webContents.send( - `on-update-achievements-${objectId}-${shop}`, - achievements - ); - }) - .catch(() => {}); + return gameAchievementsSublevel + .get(levelKey) + .then(async (gameAchievement) => { + if (gameAchievement) { + await gameAchievementsSublevel.put(levelKey, { + ...gameAchievement, + unlockedAchievements: unlockedAchievements, + }); + + if (!sendUpdateEvent) return; + + return getUnlockedAchievements(objectId, shop, true) + .then((achievements) => { + WindowManager.mainWindow?.webContents.send( + `on-update-achievements-${objectId}-${shop}`, + achievements + ); + }) + .catch(() => {}); + } }); }; @@ -46,25 +49,17 @@ export const mergeAchievements = async ( publishNotification: boolean ) => { const [localGameAchievement, userPreferences] = await Promise.all([ - gameAchievementRepository.findOne({ - where: { - objectId: game.objectID, - shop: game.shop, - }, + gameAchievementsSublevel.get(levelKeys.game(game.shop, game.objectId)), + db.get(levelKeys.userPreferences, { + valueEncoding: "json", }), - userPreferencesRepository.findOne({ where: { id: 1 } }), ]); - const achievementsData = JSON.parse( - localGameAchievement?.achievements || "[]" - ) as AchievementData[]; - - const unlockedAchievements = JSON.parse( - localGameAchievement?.unlockedAchievements || "[]" - ).filter((achievement) => achievement.name) as UnlockedAchievement[]; + const achievementsData = localGameAchievement?.achievements ?? []; + const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; const newAchievementsMap = new Map( - achievements.reverse().map((achievement) => { + achievements.toReversed().map((achievement) => { return [achievement.name.toUpperCase(), achievement]; }) ); @@ -92,7 +87,7 @@ export const mergeAchievements = async ( userPreferences?.achievementNotificationsEnabled ) { const achievementsInfo = newAchievements - .sort((a, b) => { + .toSorted((a, b) => { return a.unlockTime - b.unlockTime; }) .map((achievement) => { @@ -141,13 +136,13 @@ export const mergeAchievements = async ( if (err! instanceof SubscriptionRequiredError) { achievementsLogger.log( "Achievements not synchronized on API due to lack of subscription", - game.objectID, + game.objectId, game.title ); } return saveAchievementsOnLocal( - game.objectID, + game.objectId, game.shop, mergedLocalAchievements, publishNotification @@ -155,7 +150,7 @@ export const mergeAchievements = async ( }); } else { await saveAchievementsOnLocal( - game.objectID, + game.objectId, game.shop, mergedLocalAchievements, publishNotification diff --git a/src/main/services/achievements/update-local-unlocked-achivements.ts b/src/main/services/achievements/update-local-unlocked-achivements.ts index 0393477c..8832a475 100644 --- a/src/main/services/achievements/update-local-unlocked-achivements.ts +++ b/src/main/services/achievements/update-local-unlocked-achivements.ts @@ -4,8 +4,7 @@ import { } from "./find-achivement-files"; import { parseAchievementFile } from "./parse-achievement-file"; import { mergeAchievements } from "./merge-achievements"; -import type { UnlockedAchievement } from "@types"; -import { Game } from "@main/entity"; +import type { Game, UnlockedAchievement } from "@types"; export const updateLocalUnlockedAchivements = async (game: Game) => { const gameAchievementFiles = findAchievementFiles(game); diff --git a/src/main/services/crypto.ts b/src/main/services/crypto.ts new file mode 100644 index 00000000..63a50668 --- /dev/null +++ b/src/main/services/crypto.ts @@ -0,0 +1,28 @@ +import { safeStorage } from "electron"; +import { logger } from "./logger"; + +export class Crypto { + public static encrypt(str: string) { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(str).toString("base64"); + } else { + logger.warn( + "Encrypt method returned raw string because encryption is not available" + ); + + return str; + } + } + + public static decrypt(b64: string) { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.decryptString(Buffer.from(b64, "base64")); + } else { + logger.warn( + "Decrypt method returned raw string because encryption is not available" + ); + + return b64; + } + } +} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index e18a71ac..e6ae91e2 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,13 +1,7 @@ -import { Game } from "@main/entity"; import { Downloader } from "@shared"; import { WindowManager } from "../window-manager"; -import { - downloadQueueRepository, - gameRepository, - userPreferencesRepository, -} from "@main/repository"; import { publishDownloadCompleteNotification } from "../notifications"; -import type { DownloadProgress } from "@types"; +import type { Download, DownloadProgress, UserPreferences } from "@types"; import { GofileApi, QiwiApi, DatanodesApi } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { @@ -16,40 +10,43 @@ import { PauseDownloadPayload, } from "./types"; import { calculateETA, getDirSize } from "./helpers"; -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { RealDebridClient } from "./real-debrid"; import path from "path"; import { logger } from "../logger"; +import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { sortBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; import axios from "axios"; export class DownloadManager { - private static downloadingGameId: number | null = null; + private static downloadingGameId: string | null = null; - public static async startRPC(game?: Game, initialSeeding?: Game[]) { + public static async startRPC( + download?: Download, + downloadsToSeed?: Download[] + ) { PythonRPC.spawn( - game?.status === "active" - ? await this.getDownloadPayload(game).catch(() => undefined) + download?.status === "active" + ? await this.getDownloadPayload(download).catch(() => undefined) : undefined, - - initialSeeding?.map((game) => ({ - game_id: game.id, - url: game.uri!, - save_path: game.downloadPath!, + downloadsToSeed?.map((download) => ({ + game_id: `${download.shop}-${download.objectId}`, + url: download.uri, + save_path: download.downloadPath, })) ); - this.downloadingGameId = game?.id ?? null; + if (download) { + this.downloadingGameId = `${download.shop}-${download.objectId}`; + } } private static async getDownloadStatus() { const response = await PythonRPC.rpc.get( "/status" ); - if (response.data === null || !this.downloadingGameId) return null; - - const gameId = this.downloadingGameId; + const downloadId = this.downloadingGameId; try { const { @@ -65,24 +62,21 @@ export class DownloadManager { const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata; - const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; + const download = await downloadsSublevel.get(downloadId); + if (!isDownloadingMetadata && !isCheckingFiles) { - const update: QueryDeepPartialEntity = { + if (!download) return null; + + await downloadsSublevel.put(downloadId, { + ...download, bytesDownloaded, fileSize, progress, + folderName, status: "active", - }; - - await gameRepository.update( - { id: gameId }, - { - ...update, - folderName, - } - ); + }); } return { @@ -93,7 +87,8 @@ export class DownloadManager { isDownloadingMetadata, isCheckingFiles, progress, - gameId, + gameId: downloadId, + download, } as DownloadProgress; } catch (err) { return null; @@ -105,14 +100,22 @@ export class DownloadManager { if (status) { const { gameId, progress } = status; - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - }); - const userPreferences = await userPreferencesRepository.findOneBy({ - id: 1, - }); - if (WindowManager.mainWindow && game) { + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameId), + gamesSublevel.get(gameId), + ]); + + if (!download || !game) return; + + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + if (WindowManager.mainWindow && download) { WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.webContents.send( "on-download-progress", @@ -124,39 +127,48 @@ export class DownloadManager { ) ); } - if (progress === 1 && game) { + + if (progress === 1 && download) { publishDownloadCompleteNotification(game); if ( userPreferences?.seedAfterDownloadComplete && - game.downloader === Downloader.Torrent + download.downloader === Downloader.Torrent ) { - gameRepository.update( - { id: gameId }, - { status: "seeding", shouldSeed: true } - ); + downloadsSublevel.put(gameId, { + ...download, + status: "seeding", + shouldSeed: true, + queued: false, + }); } else { - gameRepository.update( - { id: gameId }, - { status: "complete", shouldSeed: false } - ); + downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + }); this.cancelDownload(gameId); } - await downloadQueueRepository.delete({ game }); - const [nextQueueItem] = await downloadQueueRepository.find({ - order: { - id: "DESC", - }, - relations: { - game: true, - }, - }); - if (nextQueueItem) { - this.resumeDownload(nextQueueItem.game); + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => { + return sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ); + }); + + const [nextItemOnQueue] = downloads; + + if (nextItemOnQueue) { + this.resumeDownload(nextItemOnQueue); } else { - this.downloadingGameId = -1; + this.downloadingGameId = null; } } } @@ -172,20 +184,19 @@ export class DownloadManager { logger.log(seedStatus); seedStatus.forEach(async (status) => { - const game = await gameRepository.findOne({ - where: { id: status.gameId }, - }); + const download = await downloadsSublevel.get(status.gameId); - if (!game) return; + if (!download) return; const totalSize = await getDirSize( - path.join(game.downloadPath!, status.folderName) + path.join(download.downloadPath, status.folderName) ); if (totalSize < status.fileSize) { - await this.cancelDownload(game.id); + await this.cancelDownload(status.gameId); - await gameRepository.update(game.id, { + await downloadsSublevel.put(status.gameId, { + ...download, status: "paused", shouldSeed: false, progress: totalSize / status.fileSize, @@ -198,65 +209,64 @@ export class DownloadManager { WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus); } - static async pauseDownload() { + static async pauseDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc .post("/action", { action: "pause", - game_id: this.downloadingGameId, + game_id: downloadKey, } as PauseDownloadPayload) .catch(() => {}); WindowManager.mainWindow?.setProgressBar(-1); - this.downloadingGameId = null; } - static async resumeDownload(game: Game) { - return this.startDownload(game); + static async resumeDownload(download: Download) { + return this.startDownload(download); } - static async cancelDownload(gameId = this.downloadingGameId!) { + static async cancelDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc.post("/action", { action: "cancel", - game_id: gameId, + game_id: downloadKey, }); WindowManager.mainWindow?.setProgressBar(-1); - - if (gameId === this.downloadingGameId) { + if (downloadKey === this.downloadingGameId) { this.downloadingGameId = null; } } - static async resumeSeeding(game: Game) { + static async resumeSeeding(download: Download) { await PythonRPC.rpc.post("/action", { action: "resume_seeding", - game_id: game.id, - url: game.uri, - save_path: game.downloadPath, + game_id: levelKeys.game(download.shop, download.objectId), + url: download.uri, + save_path: download.downloadPath, }); } - static async pauseSeeding(gameId: number) { + static async pauseSeeding(downloadKey: string) { await PythonRPC.rpc.post("/action", { action: "pause_seeding", - game_id: gameId, + game_id: downloadKey, }); } - private static async getDownloadPayload(game: Game) { - switch (game.downloader) { - case Downloader.Gofile: { - const id = game.uri!.split("/").pop(); + private static async getDownloadPayload(download: Download) { + const downloadId = levelKeys.game(download.shop, download.objectId); + switch (download.downloader) { + case Downloader.Gofile: { + const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadLink, - save_path: game.downloadPath!, + save_path: download.downloadPath, header: `Cookie: accountToken=${token}`, }; } @@ -269,47 +279,50 @@ export class DownloadManager { return { action: "start", - game_id: game.id, + game_id: downloadId, url: `https://pixeldrain.com/api/file/${id}?download`, save_path: game.downloadPath!, out: name, }; } case Downloader.Qiwi: { - const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!); - + const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadUrl, - save_path: game.downloadPath!, + save_path: download.downloadPath, }; } case Downloader.Datanodes: { - const downloadUrl = await DatanodesApi.getDownloadUrl(game.uri!); - + const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); return { action: "start", - game_id: game.id, + game_id: downloadId, url: downloadUrl, - save_path: game.downloadPath!, + save_path: download.downloadPath, }; } case Downloader.Torrent: return { action: "start", - game_id: game.id, - url: game.uri!, - save_path: game.downloadPath!, + game_id: downloadId, + url: download.uri, + save_path: download.downloadPath, }; case Downloader.RealDebrid: { - const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!); + const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); + + if (!downloadUrl) + throw new Error( + "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available." + ); return { action: "start", - game_id: game.id, - url: downloadUrl!, - save_path: game.downloadPath!, + game_id: downloadId, + url: downloadUrl, + save_path: download.downloadPath, }; } case Downloader.TorBox: { @@ -328,11 +341,9 @@ export class DownloadManager { } } - static async startDownload(game: Game) { - const payload = await this.getDownloadPayload(game); - + static async startDownload(download: Download) { + const payload = await this.getDownloadPayload(download); await PythonRPC.rpc.post("/action", payload); - - this.downloadingGameId = game.id; + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); } } diff --git a/src/main/services/download/types.ts b/src/main/services/download/types.ts index 8cacdcb7..0e868318 100644 --- a/src/main/services/download/types.ts +++ b/src/main/services/download/types.ts @@ -1,9 +1,9 @@ export interface PauseDownloadPayload { - game_id: number; + game_id: string; } export interface CancelDownloadPayload { - game_id: number; + game_id: string; } export enum LibtorrentStatus { @@ -24,7 +24,7 @@ export interface LibtorrentPayload { fileSize: number; folderName: string; status: LibtorrentStatus; - gameId: number; + gameId: string; } export interface ProcessPayload { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 63dd9b16..ba972b44 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -1,18 +1,18 @@ -import { - userAuthRepository, - userSubscriptionRepository, -} from "@main/repository"; import axios, { AxiosError, AxiosInstance } from "axios"; import { WindowManager } from "./window-manager"; import url from "url"; import { uploadGamesBatch } from "./library-sync"; import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; -import { logger } from "./logger"; +import { networkLogger as logger } from "./logger"; import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared"; import { omit } from "lodash-es"; import { appVersion } from "@main/constants"; import { getUserData } from "./user/get-user-data"; import { isFuture, isToday } from "date-fns"; +import { db } from "@main/level"; +import { levelKeys } from "@main/level/sublevels"; +import type { Auth, User } from "@types"; +import { Crypto } from "./crypto"; interface HydraApiOptions { needsAuth?: boolean; @@ -32,7 +32,8 @@ export class HydraApi { private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly ADD_LOG_INTERCEPTOR = true; - private static secondsToMilliseconds = (seconds: number) => seconds * 1000; + private static readonly secondsToMilliseconds = (seconds: number) => + seconds * 1000; private static userAuth: HydraApiUserAuth = { authToken: "", @@ -77,14 +78,14 @@ export class HydraApi { tokenExpirationTimestamp ); - await userAuthRepository.upsert( + db.put( + levelKeys.auth, { - id: 1, - accessToken, + accessToken: Crypto.encrypt(accessToken), + refreshToken: Crypto.encrypt(refreshToken), tokenExpirationTimestamp, - refreshToken, }, - ["id"] + { valueEncoding: "json" } ); await getUserData().then((userDetails) => { @@ -153,7 +154,8 @@ export class HydraApi { (error) => { logger.error(" ---- RESPONSE ERROR -----"); const { config } = error; - const data = JSON.parse(config.data); + + const data = JSON.parse(config.data ?? null); logger.error( config.method, @@ -174,29 +176,43 @@ export class HydraApi { error.response.status, error.response.data ); - } else if (error.request) { - const errorData = error.toJSON(); - logger.error("Request error:", errorData.message); - } else { - logger.error("Error", error.message); + + return Promise.reject(error as Error); } - logger.error(" ----- END RESPONSE ERROR -------"); - return Promise.reject(error); + + if (error.request) { + const errorData = error.toJSON(); + logger.error("Request error:", errorData.code, errorData.message); + return Promise.reject( + new Error( + `Request failed with ${errorData.code} ${errorData.message}` + ) + ); + } + + logger.error("Error", error.message); + return Promise.reject(error as Error); } ); } - const userAuth = await userAuthRepository.findOne({ - where: { id: 1 }, - relations: { subscription: true }, + const result = await db.getMany([levelKeys.auth, levelKeys.user], { + valueEncoding: "json", }); + const userAuth = result.at(0) as Auth | undefined; + const user = result.at(1) as User | undefined; + this.userAuth = { - authToken: userAuth?.accessToken ?? "", - refreshToken: userAuth?.refreshToken ?? "", + authToken: userAuth?.accessToken + ? Crypto.decrypt(userAuth.accessToken) + : "", + refreshToken: userAuth?.refreshToken + ? Crypto.decrypt(userAuth.refreshToken) + : "", expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, - subscription: userAuth?.subscription - ? { expiresAt: userAuth.subscription?.expiresAt } + subscription: user?.subscription + ? { expiresAt: user.subscription?.expiresAt } : null, }; @@ -215,38 +231,47 @@ export class HydraApi { } } - private static async revalidateAccessTokenIfExpired() { - const now = new Date(); + public static async refreshToken() { + const response = await this.instance.post(`/auth/refresh`, { + refreshToken: this.userAuth.refreshToken, + }); - if (this.userAuth.expirationTimestamp < now.getTime()) { - try { - const response = await this.instance.post(`/auth/refresh`, { - refreshToken: this.userAuth.refreshToken, - }); + const { accessToken, expiresIn } = response.data; - const { accessToken, expiresIn } = response.data; + const tokenExpirationTimestamp = + Date.now() + + this.secondsToMilliseconds(expiresIn) - + this.EXPIRATION_OFFSET_IN_MS; - const tokenExpirationTimestamp = - now.getTime() + - this.secondsToMilliseconds(expiresIn) - - this.EXPIRATION_OFFSET_IN_MS; + this.userAuth.authToken = accessToken; + this.userAuth.expirationTimestamp = tokenExpirationTimestamp; - this.userAuth.authToken = accessToken; - this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + logger.log( + "Token refreshed. New expiration:", + this.userAuth.expirationTimestamp + ); - logger.log( - "Token refreshed. New expiration:", - this.userAuth.expirationTimestamp - ); - - userAuthRepository.upsert( + await db + .get(levelKeys.auth, { valueEncoding: "json" }) + .then((auth) => { + return db.put( + levelKeys.auth, { - id: 1, - accessToken, + ...auth, + accessToken: Crypto.encrypt(accessToken), tokenExpirationTimestamp, }, - ["id"] + { valueEncoding: "json" } ); + }); + + return { accessToken, expiresIn }; + } + + private static async revalidateAccessTokenIfExpired() { + if (this.userAuth.expirationTimestamp < Date.now()) { + try { + await this.refreshToken(); } catch (err) { this.handleUnauthorizedError(err); } @@ -261,7 +286,7 @@ export class HydraApi { }; } - private static handleUnauthorizedError = (err) => { + private static readonly handleUnauthorizedError = (err) => { if (err instanceof AxiosError && err.response?.status === 401) { logger.error( "401 - Current credentials:", @@ -276,8 +301,16 @@ export class HydraApi { subscription: null, }; - userAuthRepository.delete({ id: 1 }); - userSubscriptionRepository.delete({ id: 1 }); + db.batch([ + { + type: "del", + key: levelKeys.auth, + }, + { + type: "del", + key: levelKeys.user, + }, + ]); this.sendSignOutEvent(); } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 5aaf5322..d2034f15 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,3 +1,4 @@ +export * from "./crypto"; export * from "./logger"; export * from "./steam"; export * from "./steam-250"; diff --git a/src/main/services/library-sync/clear-games-remote-id.ts b/src/main/services/library-sync/clear-games-remote-id.ts index f26d65f1..20989bc9 100644 --- a/src/main/services/library-sync/clear-games-remote-id.ts +++ b/src/main/services/library-sync/clear-games-remote-id.ts @@ -1,5 +1,16 @@ -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; -export const clearGamesRemoteIds = () => { - return gameRepository.update({}, { remoteId: null }); +export const clearGamesRemoteIds = async () => { + const games = await gamesSublevel.values().all(); + + await gamesSublevel.batch( + games.map((game) => ({ + type: "put", + key: levelKeys.game(game.shop, game.objectId), + value: { + ...game, + remoteId: null, + }, + })) + ); }; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index 6c701c9a..54718c1d 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -1,19 +1,21 @@ -import { Game } from "@main/entity"; +import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; -import { gameRepository } from "@main/repository"; +import { gamesSublevel, levelKeys } from "@main/level"; export const createGame = async (game: Game) => { return HydraApi.post(`/profile/games`, { - objectId: game.objectID, + objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, lastTimePlayed: game.lastTimePlayed, }).then((response) => { const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; - gameRepository.update( - { objectID: game.objectID }, - { remoteId, playTimeInMilliseconds, lastTimePlayed } - ); + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, + remoteId, + playTimeInMilliseconds, + lastTimePlayed, + }); }); }; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index d286ad6c..2b6eebb0 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -1,17 +1,15 @@ -import { gameRepository } from "@main/repository"; import { HydraApi } from "../hydra-api"; import { steamGamesWorker } from "@main/workers"; import { steamUrlBuilder } from "@shared"; +import { gamesSublevel, levelKeys } from "@main/level"; export const mergeWithRemoteGames = async () => { return HydraApi.get("/profile/games") .then(async (response) => { for (const game of response) { - const localGame = await gameRepository.findOne({ - where: { - objectID: game.objectId, - }, - }); + const localGame = await gamesSublevel.get( + levelKeys.game(game.shop, game.objectId) + ); if (localGame) { const updatedLastTimePlayed = @@ -26,17 +24,12 @@ export const mergeWithRemoteGames = async () => { ? game.playTimeInMilliseconds : localGame.playTimeInMilliseconds; - gameRepository.update( - { - objectID: game.objectId, - shop: "steam", - }, - { - remoteId: game.id, - lastTimePlayed: updatedLastTimePlayed, - playTimeInMilliseconds: updatedPlayTime, - } - ); + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...localGame, + remoteId: game.id, + lastTimePlayed: updatedLastTimePlayed, + playTimeInMilliseconds: updatedPlayTime, + }); } else { const steamGame = await steamGamesWorker.run(Number(game.objectId), { name: "getById", @@ -47,14 +40,15 @@ export const mergeWithRemoteGames = async () => { ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) : null; - gameRepository.insert({ - objectID: game.objectId, + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + objectId: game.objectId, title: steamGame?.name, remoteId: game.id, shop: game.shop, iconUrl, lastTimePlayed: game.lastTimePlayed, playTimeInMilliseconds: game.playTimeInMilliseconds, + isDeleted: false, }); } } diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 28c3bed3..3689b302 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -1,4 +1,4 @@ -import { Game } from "@main/entity"; +import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; export const updateGamePlaytime = async ( diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 79559a35..90c97e8b 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -1,15 +1,19 @@ -import { gameRepository } from "@main/repository"; import { chunk } from "lodash-es"; -import { IsNull } from "typeorm"; import { HydraApi } from "../hydra-api"; import { mergeWithRemoteGames } from "./merge-with-remote-games"; import { WindowManager } from "../window-manager"; import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager"; +import { gamesSublevel } from "@main/level"; export const uploadGamesBatch = async () => { - const games = await gameRepository.find({ - where: { remoteId: IsNull(), isDeleted: false }, - }); + const games = await gamesSublevel + .values() + .all() + .then((results) => { + return results.filter( + (game) => !game.isDeleted && game.remoteId === null + ); + }); const gamesChunks = chunk(games, 200); @@ -18,7 +22,7 @@ export const uploadGamesBatch = async () => { "/profile/games/batch", chunk.map((game) => { return { - objectId: game.objectID, + objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), shop: game.shop, lastTimePlayed: game.lastTimePlayed, diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 95a399ea..03bf6ad7 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -6,8 +6,12 @@ log.transports.file.resolvePathFn = ( _: log.PathVariables, message?: log.LogMessage | undefined ) => { - if (message?.scope === "python-instance") { - return path.join(logsPath, "pythoninstance.txt"); + if (message?.scope === "python-rpc") { + return path.join(logsPath, "pythonrpc.txt"); + } + + if (message?.scope === "network") { + return path.join(logsPath, "network.txt"); } if (message?.scope == "achievements") { @@ -34,3 +38,4 @@ log.initialize(); export const pythonRpcLogger = log.scope("python-rpc"); export const logger = log.scope("main"); export const achievementsLogger = log.scope("achievements"); +export const networkLogger = log.scope("network"); diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index a1c2b449..12b6e3a7 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -2,6 +2,7 @@ import { sleep } from "@main/helpers"; import { DownloadManager } from "./download"; import { watchProcesses } from "./process-watcher"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; +import { UpdateManager } from "./update-manager"; export const startMainLoop = async () => { // eslint-disable-next-line no-constant-condition @@ -11,6 +12,7 @@ export const startMainLoop = async () => { DownloadManager.watchDownloads(), AchievementWatcherManager.watchAchievements(), DownloadManager.getSeedStatus(), + UpdateManager.checkForUpdatePeriodically(), ]); await sleep(1500); diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index f3e2541b..63c666dc 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -1,8 +1,6 @@ import { Notification, app } from "electron"; import { t } from "i18next"; import trayIcon from "@resources/tray-icon.png?asset"; -import { Game } from "@main/entity"; -import { userPreferencesRepository } from "@main/repository"; import fs from "node:fs"; import axios from "axios"; import path from "node:path"; @@ -11,6 +9,9 @@ import { achievementSoundPath } from "@main/constants"; import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; +import { WindowManager } from "../window-manager"; +import type { Game, UserPreferences } from "@types"; +import { db, levelKeys } from "@main/level"; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -38,9 +39,12 @@ async function downloadImage(url: string | null) { } export const publishDownloadCompleteNotification = async (game: Game) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.downloadNotificationsEnabled) { new Notification({ @@ -93,7 +97,9 @@ export const publishCombinedNewAchievementNotification = async ( toastXml: toXmlString(options), }).show(); - if (process.platform !== "linux") { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); + } else if (process.platform !== "linux") { sound.play(achievementSoundPath); } }; @@ -140,7 +146,9 @@ export const publishNewAchievementNotification = async (info: { toastXml: toXmlString(options), }).show(); - if (process.platform !== "linux") { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); + } else if (process.platform !== "linux") { sound.play(achievementSoundPath); } }; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 4aa00bb4..bfa5cc19 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,12 +1,11 @@ -import { gameRepository } from "@main/repository"; import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; -import type { GameRunning } from "@types"; +import type { Game, GameRunning } from "@types"; import { PythonRPC } from "./python-rpc"; -import { Game } from "@main/entity"; import axios from "axios"; import { exec } from "child_process"; import { ProcessPayload } from "./download/types"; +import { gamesSublevel, levelKeys } from "@main/level"; import { createBackup } from "@main/events/cloud-save/upload-save-game"; const commands = { @@ -15,7 +14,7 @@ const commands = { }; export const gamesPlaytime = new Map< - number, + string, { lastTick: number; firstTick: number; lastSyncTick: number } >(); @@ -83,23 +82,28 @@ const findGamePathByProcess = ( const pathSet = processMap.get(executable.exe); if (pathSet) { - pathSet.forEach((path) => { + pathSet.forEach(async (path) => { if (path.toLowerCase().endsWith(executable.name)) { - gameRepository.update( - { objectID: gameId, shop: "steam" }, - { executablePath: path } - ); + const gameKey = levelKeys.game("steam", gameId); + const game = await gamesSublevel.get(gameKey); + + if (game) { + gamesSublevel.put(gameKey, { + ...game, + executablePath: path, + }); + } if (isLinuxPlatform) { exec(commands.findWineDir, (err, out) => { if (err) return; - gameRepository.update( - { objectID: gameId, shop: "steam" }, - { + if (game) { + gamesSublevel.put(gameKey, { + ...game, winePrefixPath: out.trim().replace("/drive_c/windows", ""), - } - ); + }); + } }); } } @@ -160,11 +164,12 @@ const getSystemProcessMap = async () => { }; export const watchProcesses = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((results) => { + return results.filter((game) => game.isDeleted === false); + }); if (!games.length) return; @@ -173,8 +178,8 @@ export const watchProcesses = async () => { for (const game of games) { const executablePath = game.executablePath; if (!executablePath) { - if (gameExecutables[game.objectID]) { - findGamePathByProcess(processMap, game.objectID); + if (gameExecutables[game.objectId]) { + findGamePathByProcess(processMap, game.objectId); } continue; } @@ -186,12 +191,12 @@ export const watchProcesses = async () => { const hasProcess = processMap.get(executable)?.has(executablePath); if (hasProcess) { - if (gamesPlaytime.has(game.id)) { + if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) { onTickGame(game); } else { onOpenGame(game); } - } else if (gamesPlaytime.has(game.id)) { + } else if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) { onCloseGame(game); } } @@ -203,20 +208,17 @@ export const watchProcesses = async () => { return { id: entry[0], sessionDurationInMillis: performance.now() - entry[1].firstTick, - }; + } as Pick; }); - WindowManager.mainWindow.webContents.send( - "on-games-running", - gamesRunning as Pick[] - ); + WindowManager.mainWindow.webContents.send("on-games-running", gamesRunning); } }; function onOpenGame(game: Game) { const now = performance.now(); - gamesPlaytime.set(game.id, { + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { lastTick: now, firstTick: now, lastSyncTick: now, @@ -231,16 +233,25 @@ function onOpenGame(game: Game) { function onTickGame(game: Game) { const now = performance.now(); - const gamePlaytime = gamesPlaytime.get(game.id)!; + const gamePlaytime = gamesPlaytime.get( + levelKeys.game(game.shop, game.objectId) + )!; const delta = now - gamePlaytime.lastTick; - gameRepository.update(game.id, { + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), }); - gamesPlaytime.set(game.id, { + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + ...game, + playTimeInMilliseconds: game.playTimeInMilliseconds + delta, + lastTimePlayed: new Date(), + }); + + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastTick: now, }); @@ -256,7 +267,7 @@ function onTickGame(game: Game) { gamePromise .then(() => { - gamesPlaytime.set(game.id, { + gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastSyncTick: now, }); @@ -266,8 +277,10 @@ function onTickGame(game: Game) { } const onCloseGame = (game: Game) => { - const gamePlaytime = gamesPlaytime.get(game.id)!; - gamesPlaytime.delete(game.id); + const gamePlaytime = gamesPlaytime.get( + levelKeys.game(game.shop, game.objectId) + )!; + gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId)); if (game.remoteId) { // create backup diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 1384a1be..22e60461 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -10,7 +10,7 @@ import { Readable } from "node:stream"; import { app, dialog } from "electron"; interface GamePayload { - game_id: number; + game_id: string; url: string; save_path: string; } diff --git a/src/main/services/update-manager.ts b/src/main/services/update-manager.ts new file mode 100644 index 00000000..9a277dd7 --- /dev/null +++ b/src/main/services/update-manager.ts @@ -0,0 +1,60 @@ +import updater, { UpdateInfo } from "electron-updater"; +import { logger, WindowManager } from "@main/services"; +import { AppUpdaterEvent } from "@types"; +import { app } from "electron"; +import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; + +const isAutoInstallAvailable = + process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; + +const { autoUpdater } = updater; +const sendEventsForDebug = false; + +export class UpdateManager { + private static hasNotified = false; + private static newVersion = ""; + private static checkTick = 0; + + private static mockValuesForDebug() { + this.sendEvent({ type: "update-available", info: { version: "1.3.0" } }); + this.sendEvent({ type: "update-downloaded" }); + } + + private static sendEvent(event: AppUpdaterEvent) { + WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); + } + + public static checkForUpdates() { + autoUpdater + .once("update-available", (info: UpdateInfo) => { + this.sendEvent({ type: "update-available", info }); + this.newVersion = info.version; + }) + .once("update-downloaded", () => { + this.sendEvent({ type: "update-downloaded" }); + + if (!this.hasNotified) { + this.hasNotified = true; + publishNotificationUpdateReadyToInstall(this.newVersion); + } + }); + + if (app.isPackaged) { + autoUpdater.autoDownload = isAutoInstallAvailable; + autoUpdater.checkForUpdates().then((result) => { + logger.log(`Check for updates result: ${result}`); + }); + } else if (sendEventsForDebug) { + this.mockValuesForDebug(); + } + + return isAutoInstallAvailable; + } + + public static checkForUpdatePeriodically() { + if (this.checkTick % 2000 == 0) { + this.checkForUpdates(); + } + this.checkTick++; + } +} diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index 7e924454..d26c995d 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -1,60 +1,45 @@ -import type { ProfileVisibility, UserDetails } from "@types"; +import { User, type ProfileVisibility, type UserDetails } from "@types"; import { HydraApi } from "../hydra-api"; -import { - userAuthRepository, - userSubscriptionRepository, -} from "@main/repository"; import { UserNotLoggedInError } from "@shared"; import { logger } from "../logger"; +import { db } from "@main/level"; +import { levelKeys } from "@main/level/sublevels"; -export const getUserData = () => { +export const getUserData = async () => { return HydraApi.get(`/profile/me`) .then(async (me) => { - userAuthRepository.upsert( - { - id: 1, - displayName: me.displayName, - profileImageUrl: me.profileImageUrl, - backgroundImageUrl: me.backgroundImageUrl, - userId: me.id, - }, - ["id"] + db.get(levelKeys.user, { valueEncoding: "json" }).then( + (user) => { + return db.put( + levelKeys.user, + { + ...user, + id: me.id, + displayName: me.displayName, + profileImageUrl: me.profileImageUrl, + backgroundImageUrl: me.backgroundImageUrl, + subscription: me.subscription, + }, + { valueEncoding: "json" } + ); + } ); - if (me.subscription) { - await userSubscriptionRepository.upsert( - { - id: 1, - subscriptionId: me.subscription?.id || "", - status: me.subscription?.status || "", - planId: me.subscription?.plan.id || "", - planName: me.subscription?.plan.name || "", - expiresAt: me.subscription?.expiresAt || null, - user: { id: 1 }, - }, - ["id"] - ); - } else { - await userSubscriptionRepository.delete({ id: 1 }); - } - return me; }) .catch(async (err) => { if (err instanceof UserNotLoggedInError) { - logger.info("User is not logged in", err); return null; } logger.error("Failed to get logged user"); - const loggedUser = await userAuthRepository.findOne({ - where: { id: 1 }, - relations: { subscription: true }, + + const loggedUser = await db.get(levelKeys.user, { + valueEncoding: "json", }); if (loggedUser) { return { ...loggedUser, - id: loggedUser.userId, username: "", bio: "", email: null, @@ -64,15 +49,16 @@ export const getUserData = () => { }, subscription: loggedUser.subscription ? { - id: loggedUser.subscription.subscriptionId, + id: loggedUser.subscription.id, status: loggedUser.subscription.status, plan: { - id: loggedUser.subscription.planId, - name: loggedUser.subscription.planName, + id: loggedUser.subscription.plan.id, + name: loggedUser.subscription.plan.name, }, expiresAt: loggedUser.subscription.expiresAt, } : null, + featurebaseJwt: "", } as UserDetails; } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index a7cfcee2..70e7255c 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -9,14 +9,16 @@ import { shell, } from "electron"; import { is } from "@electron-toolkit/utils"; -import i18next, { t } from "i18next"; +import { t } from "i18next"; import path from "node:path"; import icon from "@resources/icon.png?asset"; import trayIcon from "@resources/tray-icon.png?asset"; -import { gameRepository, userPreferencesRepository } from "@main/repository"; -import { IsNull, Not } from "typeorm"; import { HydraApi } from "./hydra-api"; import UserAgent from "user-agents"; +import { db, gamesSublevel, levelKeys } from "@main/level"; +import { slice, sortBy } from "lodash-es"; +import type { UserPreferences } from "@types"; +import { AuthPage } from "@shared"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -48,7 +50,7 @@ export class WindowManager { minHeight: 540, backgroundColor: "#1c1c1c", titleBarStyle: process.platform === "linux" ? "default" : "hidden", - ...(process.platform === "linux" ? { icon } : {}), + icon, trafficLightPosition: { x: 16, y: 16 }, titleBarOverlay: { symbolColor: "#DADBE1", @@ -130,9 +132,12 @@ export class WindowManager { }); this.mainWindow.on("close", async () => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); if (userPreferences?.preferQuitInsteadOfHiding) { app.quit(); @@ -140,9 +145,14 @@ export class WindowManager { WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow = null; }); + + this.mainWindow.webContents.setWindowOpenHandler((handler) => { + shell.openExternal(handler.url); + return { action: "deny" }; + }); } - public static openAuthWindow() { + public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { if (this.mainWindow) { const authWindow = new BrowserWindow({ width: 600, @@ -164,12 +174,8 @@ export class WindowManager { if (!app.isPackaged) authWindow.webContents.openDevTools(); - const searchParams = new URLSearchParams({ - lng: i18next.language, - }); - authWindow.loadURL( - `${import.meta.env.MAIN_VITE_AUTH_URL}/?${searchParams.toString()}` + `${import.meta.env.MAIN_VITE_AUTH_URL}${page}?${searchParams.toString()}` ); authWindow.once("ready-to-show", () => { @@ -181,6 +187,13 @@ export class WindowManager { authWindow.close(); HydraApi.handleExternalAuth(url); + return; + } + + if (url.startsWith("hydralauncher://update-account")) { + authWindow.close(); + + WindowManager.mainWindow?.webContents.send("on-account-updated"); } }); } @@ -207,17 +220,19 @@ export class WindowManager { } const updateSystemTray = async () => { - const games = await gameRepository.find({ - where: { - isDeleted: false, - executablePath: Not(IsNull()), - lastTimePlayed: Not(IsNull()), - }, - take: 5, - order: { - lastTimePlayed: "DESC", - }, - }); + const games = await gamesSublevel + .values() + .all() + .then((games) => { + const filteredGames = games.filter( + (game) => + !game.isDeleted && game.executablePath && game.lastTimePlayed + ); + + const sortedGames = sortBy(filteredGames, "lastTimePlayed", "DESC"); + + return slice(sortedGames, 5); + }); const recentlyPlayedGames: Array = games.map(({ title, executablePath }) => ({ diff --git a/src/preload/index.ts b/src/preload/index.ts index 316397d2..eac3c0a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,23 +15,23 @@ import type { SeedingStatus, GameAchievement, } from "@types"; -import type { CatalogueCategory } from "@shared"; +import type { AuthPage, CatalogueCategory } from "@shared"; import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { /* Torrenting */ startGameDownload: (payload: StartGameDownloadPayload) => ipcRenderer.invoke("startGameDownload", payload), - cancelGameDownload: (gameId: number) => - ipcRenderer.invoke("cancelGameDownload", gameId), - pauseGameDownload: (gameId: number) => - ipcRenderer.invoke("pauseGameDownload", gameId), - resumeGameDownload: (gameId: number) => - ipcRenderer.invoke("resumeGameDownload", gameId), - pauseGameSeed: (gameId: number) => - ipcRenderer.invoke("pauseGameSeed", gameId), - resumeGameSeed: (gameId: number) => - ipcRenderer.invoke("resumeGameSeed", gameId), + cancelGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("cancelGameDownload", shop, objectId), + pauseGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("pauseGameDownload", shop, objectId), + resumeGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resumeGameDownload", shop, objectId), + pauseGameSeed: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("pauseGameSeed", shop, objectId), + resumeGameSeed: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resumeGameSeed", shop, objectId), onDownloadProgress: (cb: (value: DownloadProgress) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -98,40 +98,61 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("putDownloadSource", objectIds), /* Library */ - addGameToLibrary: (objectId: string, title: string, shop: GameShop) => - ipcRenderer.invoke("addGameToLibrary", objectId, title, shop), - createGameShortcut: (id: number) => - ipcRenderer.invoke("createGameShortcut", id), - updateExecutablePath: (id: number, executablePath: string | null) => - ipcRenderer.invoke("updateExecutablePath", id, executablePath), - updateLaunchOptions: (id: number, launchOptions: string | null) => - ipcRenderer.invoke("updateLaunchOptions", id, launchOptions), - selectGameWinePrefix: (id: number, winePrefixPath: string | null) => - ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath), + addGameToLibrary: (shop: GameShop, objectId: string, title: string) => + ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), + createGameShortcut: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("createGameShortcut", shop, objectId), + updateExecutablePath: ( + shop: GameShop, + objectId: string, + executablePath: string | null + ) => + ipcRenderer.invoke("updateExecutablePath", shop, objectId, executablePath), + updateLaunchOptions: ( + shop: GameShop, + objectId: string, + launchOptions: string | null + ) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions), + selectGameWinePrefix: ( + shop: GameShop, + objectId: string, + winePrefixPath: string | null + ) => + ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath), verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), - openGameInstaller: (gameId: number) => - ipcRenderer.invoke("openGameInstaller", gameId), - openGameInstallerPath: (gameId: number) => - ipcRenderer.invoke("openGameInstallerPath", gameId), - openGameExecutablePath: (gameId: number) => - ipcRenderer.invoke("openGameExecutablePath", gameId), + openGameInstaller: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameInstaller", shop, objectId), + openGameInstallerPath: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameInstallerPath", shop, objectId), + openGameExecutablePath: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("openGameExecutablePath", shop, objectId), openGame: ( - gameId: number, + shop: GameShop, + objectId: string, executablePath: string, - launchOptions: string | null - ) => ipcRenderer.invoke("openGame", gameId, executablePath, launchOptions), - closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId), - removeGameFromLibrary: (gameId: number) => - ipcRenderer.invoke("removeGameFromLibrary", gameId), - removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId), - deleteGameFolder: (gameId: number) => - ipcRenderer.invoke("deleteGameFolder", gameId), - getGameByObjectId: (objectId: string) => - ipcRenderer.invoke("getGameByObjectId", objectId), - resetGameAchievements: (gameId: number) => - ipcRenderer.invoke("resetGameAchievements", gameId), + launchOptions?: string | null + ) => + ipcRenderer.invoke( + "openGame", + shop, + objectId, + executablePath, + launchOptions + ), + closeGame: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("closeGame", shop, objectId), + removeGameFromLibrary: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("removeGameFromLibrary", shop, objectId), + removeGame: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("removeGame", shop, objectId), + deleteGameFolder: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("deleteGameFolder", shop, objectId), + getGameByObjectId: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("getGameByObjectId", shop, objectId), + resetGameAchievements: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("resetGameAchievements", shop, objectId), onGamesRunning: ( cb: ( gamesRunning: Pick[] @@ -148,6 +169,12 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-library-batch-complete", listener); }, + onAchievementUnlocked: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-achievement-unlocked", listener); + return () => + ipcRenderer.removeListener("on-achievement-unlocked", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => @@ -291,13 +318,19 @@ contextBridge.exposeInMainWorld("electron", { /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), - openAuthWindow: () => ipcRenderer.invoke("openAuthWindow"), + openAuthWindow: (page: AuthPage) => + ipcRenderer.invoke("openAuthWindow", page), getSessionHash: () => ipcRenderer.invoke("getSessionHash"), onSignIn: (cb: () => void) => { const listener = (_event: Electron.IpcRendererEvent) => cb(); ipcRenderer.on("on-signin", listener); return () => ipcRenderer.removeListener("on-signin", listener); }, + onAccountUpdated: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-account-updated", listener); + return () => ipcRenderer.removeListener("on-account-updated", listener); + }, onSignOut: (cb: () => void) => { const listener = (_event: Electron.IpcRendererEvent) => cb(); ipcRenderer.on("on-signout", listener); diff --git a/src/renderer/src/app.css.ts b/src/renderer/src/app.css.ts index 25c453c8..b5c4740e 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -123,7 +123,7 @@ export const titleBar = style({ alignItems: "center", padding: `0 ${SPACING_UNIT * 2}px`, WebkitAppRegion: "drag", - zIndex: "4", + zIndex: vars.zIndex.titleBar, borderBottom: `1px solid ${vars.color.border}`, } as ComplexStyleRule); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 5fefe90c..411e2c04 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; - +import achievementSound from "@renderer/assets/audio/achievement.wav"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -84,7 +84,7 @@ export function App() { useEffect(() => { const unsubscribe = window.electron.onDownloadProgress( (downloadProgress) => { - if (downloadProgress.game.progress === 1) { + if (downloadProgress.progress === 1) { clearDownload(); updateLibrary(); return; @@ -233,13 +233,29 @@ export function App() { downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); }, [updateRepacks]); + const playAudio = useCallback(() => { + const audio = new Audio(achievementSound); + audio.volume = 0.2; + audio.play(); + }, []); + + useEffect(() => { + const unsubscribe = window.electron.onAchievementUnlocked(() => { + playAudio(); + }); + + return () => { + unsubscribe(); + }; + }, [playAudio]); + const handleToastClose = useCallback(() => { dispatch(closeToast()); }, [dispatch]); return ( <> - {window.electron.platform === "win32" && ( + {/* {window.electron.platform === "win32" && (

Hydra @@ -248,7 +264,15 @@ export function App() { )}

- )} + )} */} +
+

+ Hydra + {hasActiveSubscription && ( + Cloud + )} +

+
(""); @@ -32,27 +34,29 @@ export function BottomPanel() { const status = useMemo(() => { if (isGameDownloading) { + const game = library.find((game) => game.id === lastPacket?.gameId)!; + if (lastPacket?.isCheckingFiles) return t("checking_files", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); if (lastPacket?.isDownloadingMetadata) return t("downloading_metadata", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); if (!eta) { return t("calculating_eta", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, }); } return t("downloading", { - title: lastPacket?.game.title, + title: game.title, percentage: progress, eta, speed: downloadSpeed, @@ -60,16 +64,7 @@ export function BottomPanel() { } return t("no_downloads_in_progress"); - }, [ - t, - isGameDownloading, - lastPacket?.game, - lastPacket?.isDownloadingMetadata, - lastPacket?.isCheckingFiles, - progress, - eta, - downloadSpeed, - ]); + }, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]); return (
@@ -81,10 +76,15 @@ export function BottomPanel() { {status} - - {sessionHash ? `${sessionHash} -` : ""} v{version} " - {VERSION_CODENAME}" - +
); } diff --git a/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx b/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx index 14531868..9e3a1dec 100644 --- a/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx +++ b/src/renderer/src/components/dropdown-menu/dropdown-menu.tsx @@ -29,7 +29,7 @@ export function DropdownMenu({ loop = true, align = "center", alignOffset = 0, -}: DropdownMenuProps) { +}: Readonly) { return ( diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index d8d0554d..af09ef38 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -52,6 +52,7 @@ export function Modal({ ) ) return false; + const openModals = document.querySelectorAll("[role=dialog]"); return ( diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 49e56ab7..3897ac54 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { Avatar } from "../avatar/avatar"; +import { AuthPage } from "@shared"; const LONG_POLLING_INTERVAL = 120_000; @@ -26,11 +27,11 @@ export function SidebarProfile() { const handleProfileClick = () => { if (userDetails === null) { - window.electron.openAuthWindow(); + window.electron.openAuthWindow(AuthPage.SignIn); return; } - navigate(`/profile/${userDetails!.id}`); + navigate(`/profile/${userDetails.id}`); }; useEffect(() => { diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 355d04b2..aa0dea8e 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -56,7 +56,7 @@ export function Sidebar() { useEffect(() => { updateLibrary(); - }, [lastPacket?.game.id, updateLibrary]); + }, [lastPacket?.gameId, updateLibrary]); const sidebarRef = useRef(null); @@ -118,18 +118,17 @@ export function Sidebar() { }, [isResizing]); const getGameTitle = (game: LibraryGame) => { - if (lastPacket?.game.id === game.id) { + if (lastPacket?.gameId === game.id) { return t("downloading", { title: game.title, percentage: progress, }); } - if (game.downloadQueue !== null) { - return t("queued", { title: game.title }); - } + if (game.download?.queued) return t("queued", { title: game.title }); - if (game.status === "paused") return t("paused", { title: game.title }); + if (game.download?.status === "paused") + return t("paused", { title: game.title }); return game.title; }; @@ -146,7 +145,7 @@ export function Sidebar() { ) => { const path = buildGameDetailsPath({ ...game, - objectId: game.objectID, + objectId: game.objectId, }); if (path !== location.pathname) { navigate(path); @@ -155,7 +154,8 @@ export function Sidebar() { if (event.detail === 2) { if (game.executablePath) { window.electron.openGame( - game.id, + game.shop, + game.objectId, game.executablePath, game.launchOptions ); @@ -219,12 +219,12 @@ export function Sidebar() {
    {filteredLibrary.map((game) => (
  • - ) - } - /> - )} + + + {t("clear")} + + ) + } + /> +

    {t("downloads_secion_title")}

    @@ -322,7 +335,7 @@ export function GameOptionsModal({ > {t("open_download_options")} - {game.downloadPath && ( + {game.download?.downloadPath && ( 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 635c7f99..b2b7b6f8 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -67,8 +67,8 @@ export function RepacksModal({ }; const checkIfLastDownloadedOption = (repack: GameRepack) => { - if (!game) return false; - return repack.uris.some((uri) => uri.includes(game.uri!)); + if (!game?.download) return false; + return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; return ( diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx index da9d078f..e24f677b 100644 --- a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon } from "@primer/octicons-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import * as styles from "./sidebar-section.css"; @@ -11,6 +11,15 @@ export interface SidebarSectionProps { export function SidebarSection({ title, children }: SidebarSectionProps) { const content = useRef(null); const [isOpen, setIsOpen] = useState(true); + const [height, setHeight] = useState(0); + + useEffect(() => { + if (content.current && content.current.scrollHeight !== height) { + setHeight(isOpen ? content.current.scrollHeight : 0); + } else if (!isOpen) { + setHeight(0); + } + }, [isOpen, children, height]); return (
    @@ -26,7 +35,7 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
    {t("sign_in_to_see_achievements")}
      - {fakeAchievements.map((achievement, index) => ( + {achievementsPlaceholder.map((achievement, index) => (
    • { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -148,7 +145,6 @@ export function ProfileContent() { userStats, numberFormatter, t, - navigate, statsIndex, ]); diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index 0aaee611..c6b024dd 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -254,7 +254,7 @@ export function ProfileHero() { if (gameRunning) return { ...gameRunning, - objectId: gameRunning.objectID, + objectId: gameRunning.objectId, sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000, }; diff --git a/src/renderer/src/pages/settings/settings-privacy.css.ts b/src/renderer/src/pages/settings/settings-account.css.ts similarity index 80% rename from src/renderer/src/pages/settings/settings-privacy.css.ts rename to src/renderer/src/pages/settings/settings-account.css.ts index 2aec8cd0..8fbb3845 100644 --- a/src/renderer/src/pages/settings/settings-privacy.css.ts +++ b/src/renderer/src/pages/settings/settings-account.css.ts @@ -5,14 +5,7 @@ import { SPACING_UNIT, vars } from "../../theme.css"; export const form = style({ display: "flex", flexDirection: "column", - gap: `${SPACING_UNIT}px`, -}); - -export const blockedUserAvatar = style({ - width: "32px", - height: "32px", - borderRadius: "4px", - filter: "grayscale(100%)", + gap: `${SPACING_UNIT * 3}px`, }); export const blockedUser = style({ @@ -43,5 +36,4 @@ export const blockedUsersList = style({ flexDirection: "column", alignItems: "flex-start", gap: `${SPACING_UNIT}px`, - marginTop: `${SPACING_UNIT}px`, }); diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx new file mode 100644 index 00000000..e8bac125 --- /dev/null +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -0,0 +1,291 @@ +import { Avatar, Button, SelectField } from "@renderer/components"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import * as styles from "./settings-account.css"; +import { useDate, useToast, useUserDetails } from "@renderer/hooks"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { + CloudIcon, + KeyIcon, + MailIcon, + XCircleFillIcon, +} from "@primer/octicons-react"; +import { settingsContext } from "@renderer/context"; +import { AuthPage } from "@shared"; + +interface FormValues { + profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE"; +} + +export function SettingsAccount() { + const { t } = useTranslation("settings"); + + const [isUnblocking, setIsUnblocking] = useState(false); + + const { showSuccessToast } = useToast(); + + const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext); + + const { formatDate } = useDate(); + + const { + control, + formState: { isSubmitting }, + setValue, + handleSubmit, + } = useForm(); + + const { + userDetails, + hasActiveSubscription, + patchUser, + fetchUserDetails, + updateUserDetails, + unblockUser, + } = useUserDetails(); + + useEffect(() => { + if (userDetails?.profileVisibility) { + setValue("profileVisibility", userDetails.profileVisibility); + } + }, [userDetails, setValue]); + + useEffect(() => { + const unsubscribe = window.electron.onAccountUpdated(() => { + fetchUserDetails().then((response) => { + if (response) { + updateUserDetails(response); + } + }); + showSuccessToast(t("account_data_updated_successfully")); + }); + + return () => { + unsubscribe(); + }; + }, [fetchUserDetails, updateUserDetails, showSuccessToast]); + + const visibilityOptions = [ + { value: "PUBLIC", label: t("public") }, + { value: "FRIENDS", label: t("friends_only") }, + { value: "PRIVATE", label: t("private") }, + ]; + + const onSubmit = async (values: FormValues) => { + await patchUser(values); + showSuccessToast(t("changes_saved")); + }; + + const handleUnblockClick = useCallback( + (id: string) => { + setIsUnblocking(true); + + unblockUser(id) + .then(() => { + fetchBlockedUsers(); + showSuccessToast(t("user_unblocked")); + }) + .finally(() => { + setIsUnblocking(false); + }); + }, + [unblockUser, fetchBlockedUsers, t, showSuccessToast] + ); + + const getHydraCloudSectionContent = () => { + const hasSubscribedBefore = Boolean(userDetails?.subscription?.expiresAt); + const isRenewalActive = userDetails?.subscription?.status === "active"; + + if (!hasSubscribedBefore) { + return { + description: {t("no_subscription")}, + callToAction: t("become_subscriber"), + }; + } + + if (hasActiveSubscription) { + return { + description: isRenewalActive ? ( + <> + + {t("subscription_renews_on", { + date: formatDate(userDetails.subscription!.expiresAt!), + })} + + {t("bill_sent_until")} + + ) : ( + <> + {t("subscription_renew_cancelled")} + + {t("subscription_active_until", { + date: formatDate(userDetails!.subscription!.expiresAt!), + })} + + + ), + callToAction: t("manage_subscription"), + }; + } + + return { + description: ( + + {t("subscription_expired_at", { + date: formatDate(userDetails!.subscription!.expiresAt!), + })} + + ), + callToAction: t("renew_subscription"), + }; + }; + + if (!userDetails) return null; + + return ( +
      + { + const handleChange = ( + event: React.ChangeEvent + ) => { + field.onChange(event); + handleSubmit(onSubmit)(); + }; + + return ( +
      + ({ + key: visiblity.value, + value: visiblity.value, + label: visiblity.label, + }))} + disabled={isSubmitting} + /> + + {t("profile_visibility_description")} +
      + ); + }} + /> + +
      +

      {t("current_email")}

      +

      {userDetails?.email ?? t("no_email_account")}

      + +
      + + + +
      +
      + +
      +

      Hydra Cloud

      +
      + {getHydraCloudSectionContent().description} +
      + + +
      + +
      +

      {t("blocked_users")}

      + + {blockedUsers.length > 0 ? ( +
        + {blockedUsers.map((user) => { + return ( +
      • +
        + + {user.displayName} +
        + + +
      • + ); + })} +
      + ) : ( + {t("no_users_blocked")} + )} +
      + + ); +} diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 6737c4b7..ba0411a5 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -57,10 +57,30 @@ export function SettingsGeneral() { ); }, []); - useEffect(updateFormWithUserPreferences, [ - userPreferences, - defaultDownloadsPath, - ]); + useEffect(() => { + if (userPreferences) { + const languageKeys = Object.keys(languageResources); + const language = + languageKeys.find( + (language) => language === userPreferences.language + ) ?? + languageKeys.find((language) => { + return language.startsWith(userPreferences.language.split("-")[0]); + }); + + setForm((prev) => ({ + ...prev, + downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath, + downloadNotificationsEnabled: + userPreferences.downloadNotificationsEnabled, + repackUpdatesNotificationsEnabled: + userPreferences.repackUpdatesNotificationsEnabled, + achievementNotificationsEnabled: + userPreferences.achievementNotificationsEnabled, + language: language ?? "en", + })); + } + }, [userPreferences, defaultDownloadsPath]); const handleLanguageChange = (event) => { const value = event.target.value; @@ -86,31 +106,6 @@ export function SettingsGeneral() { } }; - function updateFormWithUserPreferences() { - if (userPreferences) { - const languageKeys = Object.keys(languageResources); - const language = - languageKeys.find((language) => { - return language === userPreferences.language; - }) ?? - languageKeys.find((language) => { - return language.startsWith(userPreferences.language.split("-")[0]); - }); - - setForm((prev) => ({ - ...prev, - downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath, - downloadNotificationsEnabled: - userPreferences.downloadNotificationsEnabled, - repackUpdatesNotificationsEnabled: - userPreferences.repackUpdatesNotificationsEnabled, - achievementNotificationsEnabled: - userPreferences.achievementNotificationsEnabled, - language: language ?? "en", - })); - } - } - return ( <> (); - - const { patchUser, userDetails } = useUserDetails(); - - const { unblockUser } = useUserDetails(); - - useEffect(() => { - if (userDetails?.profileVisibility) { - setValue("profileVisibility", userDetails.profileVisibility); - } - }, [userDetails, setValue]); - - const visibilityOptions = [ - { value: "PUBLIC", label: t("public") }, - { value: "FRIENDS", label: t("friends_only") }, - { value: "PRIVATE", label: t("private") }, - ]; - - const onSubmit = async (values: FormValues) => { - await patchUser(values); - showSuccessToast(t("changes_saved")); - }; - - const handleUnblockClick = useCallback( - (id: string) => { - setIsUnblocking(true); - - unblockUser(id) - .then(() => { - fetchBlockedUsers(); - showSuccessToast(t("user_unblocked")); - }) - .finally(() => { - setIsUnblocking(false); - }); - }, - [unblockUser, fetchBlockedUsers, t, showSuccessToast] - ); - - return ( -
      - { - const handleChange = ( - event: React.ChangeEvent - ) => { - field.onChange(event); - handleSubmit(onSubmit)(); - }; - - return ( - <> - ({ - key: visiblity.value, - value: visiblity.value, - label: visiblity.label, - }))} - disabled={isSubmitting} - /> - - {t("profile_visibility_description")} - - ); - }} - /> - -

      - {t("blocked_users")} -

      - -
        - {blockedUsers.map((user) => { - return ( -
      • -
        - {user.displayName} - {user.displayName} -
        - - -
      • - ); - })} -
      - - ); -} diff --git a/src/renderer/src/pages/settings/settings.tsx b/src/renderer/src/pages/settings/settings.tsx index 00ceebd7..702da53e 100644 --- a/src/renderer/src/pages/settings/settings.tsx +++ b/src/renderer/src/pages/settings/settings.tsx @@ -11,7 +11,7 @@ import { SettingsContextConsumer, SettingsContextProvider, } from "@renderer/context"; -import { SettingsPrivacy } from "./settings-privacy"; +import { SettingsAccount } from "./settings-account"; import { useUserDetails } from "@renderer/hooks"; import { useMemo } from "react"; @@ -28,7 +28,7 @@ export default function Settings() { t("debrid_services"), ]; - if (userDetails) return [...categories, t("privacy")]; + if (userDetails) return [...categories, t("account")]; return categories; }, [userDetails, t]); @@ -53,7 +53,7 @@ export default function Settings() { return ; } - return ; + return ; }; return ( diff --git a/src/renderer/src/scss/globals.scss b/src/renderer/src/scss/globals.scss index cc01c197..792abf86 100644 --- a/src/renderer/src/scss/globals.scss +++ b/src/renderer/src/scss/globals.scss @@ -16,6 +16,6 @@ $spacing-unit: 8px; $toast-z-index: 5; $bottom-panel-z-index: 3; -$title-bar-z-index: 4; +$title-bar-z-index: 1900000001; $backdrop-z-index: 4; $modal-z-index: 5; diff --git a/src/renderer/src/theme.css.ts b/src/renderer/src/theme.css.ts index b9fbaf55..7cd92ef3 100644 --- a/src/renderer/src/theme.css.ts +++ b/src/renderer/src/theme.css.ts @@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", { zIndex: { toast: "5", bottomPanel: "3", - titleBar: "4", + titleBar: "1900000001", backdrop: "4", }, }); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e5a3f6ae..0fa2e3a1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -43,3 +43,9 @@ export enum Cracker { rle = "RLE", razor1911 = "RAZOR1911", } + +export enum AuthPage { + SignIn = "/", + UpdateEmail = "/update-email", + UpdatePassword = "/update-password", +} diff --git a/src/types/download.types.ts b/src/types/download.types.ts new file mode 100644 index 00000000..8b7f2091 --- /dev/null +++ b/src/types/download.types.ts @@ -0,0 +1,176 @@ +import type { Download } from "./level.types"; + +export type DownloadStatus = + | "active" + | "waiting" + | "paused" + | "error" + | "complete" + | "seeding" + | "removed"; + +export interface DownloadProgress { + downloadSpeed: number; + timeRemaining: number; + numPeers: number; + numSeeds: number; + isDownloadingMetadata: boolean; + isCheckingFiles: boolean; + progress: number; + gameId: string; + download: Download; +} + +/* Torbox */ +export interface TorBoxUser { + id: number; + email: string; + plan: string; + expiration: string; +} + +export interface TorBoxUserRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxUser; +} + +export interface TorBoxFile { + id: number; + md5: string; + s3_path: string; + name: string; + size: number; + mimetype: string; + short_name: string; +} + +export interface TorBoxTorrentInfo { + id: number; + hash: string; + created_at: string; + updated_at: string; + magnet: string; + size: number; + active: boolean; + cached: boolean; + auth_id: string; + download_state: + | "downloading" + | "uploading" + | "stalled (no seeds)" + | "paused" + | "completed" + | "cached" + | "metaDL" + | "checkingResumeData"; + seeds: number; + ratio: number; + progress: number; + download_speed: number; + upload_speed: number; + name: string; + eta: number; + files: TorBoxFile[]; +} + +export interface TorBoxTorrentInfoRequest { + success: boolean; + detail: string; + error: string; + data: TorBoxTorrentInfo[]; +} + +export interface TorBoxAddTorrentRequest { + success: boolean; + detail: string; + error: string; + data: { + torrent_id: number; + name: string; + hash: string; + }; +} + +export interface TorBoxRequestLinkRequest { + success: boolean; + detail: string; + error: string; + data: string; +} + +/* Real-Debrid */ +export interface RealDebridUnrestrictLink { + id: string; + filename: string; + mimeType: string; + filesize: number; + link: string; + host: string; + host_icon: string; + chunks: number; + crc: number; + download: string; + streamable: number; +} + +export interface RealDebridAddMagnet { + id: string; + // URL of the created resource + uri: string; +} + +export interface RealDebridTorrentInfo { + id: string; + filename: string; + original_filename: string; + hash: string; + bytes: number; + original_bytes: number; + host: string; + split: number; + progress: number; + status: + | "magnet_error" + | "magnet_conversion" + | "waiting_files_selection" + | "queued" + | "downloading" + | "downloaded" + | "error" + | "virus" + | "compressing" + | "uploading" + | "dead"; + added: string; + files: { + id: number; + path: string; + bytes: number; + selected: number; + }[]; + links: string[]; + ended: string; + speed: number; + seeders: number; +} + +export interface RealDebridUser { + id: number; + username: string; + email: string; + points: number; + locale: string; + avatar: string; + type: string; + premium: number; + expiration: string; +} + +/* Torrent */ +export interface SeedingStatus { + gameId: string; + status: DownloadStatus; + uploadSpeed: number; +} diff --git a/src/types/game.types.ts b/src/types/game.types.ts new file mode 100644 index 00000000..e1ba779b --- /dev/null +++ b/src/types/game.types.ts @@ -0,0 +1,21 @@ +export type GameShop = "steam" | "epic"; + +export interface UnlockedAchievement { + name: string; + unlockTime: number; +} + +export interface SteamAchievement { + name: string; + displayName: string; + description?: string; + icon: string; + icongray: string; + hidden: boolean; + points?: number; +} + +export interface UserAchievement extends SteamAchievement { + unlocked: boolean; + unlockTime: number | null; +} diff --git a/src/types/index.ts b/src/types/index.ts index da5deea4..f436551f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,7 @@ import type { Cracker, DownloadSourceStatus, Downloader } from "@shared"; import type { SteamAppDetails } from "./steam.types"; - -export type GameStatus = - | "active" - | "waiting" - | "paused" - | "error" - | "complete" - | "seeding" - | "removed"; - -export type GameShop = "steam" | "epic"; +import type { Download, Game, Subscription } from "./level.types"; +import type { GameShop } from "./game.types"; export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; @@ -33,48 +24,6 @@ export interface GameRepack { updatedAt: Date; } -export interface AchievementData { - name: string; - displayName: string; - description?: string; - icon: string; - icongray: string; - hidden: boolean; - points?: number; -} - -export interface UserAchievement { - name: string; - hidden: boolean; - displayName: string; - points?: number; - description?: string; - unlocked: boolean; - unlockTime: number | null; - icon: string; - icongray: string; -} - -export interface RemoteUnlockedAchievement { - name: string; - hidden: boolean; - icon: string; - displayName: string; - description?: string; - unlockTime: number; -} - -export interface GameAchievement { - name: string; - hidden: boolean; - displayName: string; - description?: string; - unlocked: boolean; - unlockTime: number | null; - icon: string; - icongray: string; -} - export type ShopDetails = SteamAppDetails & { objectId: string; }; @@ -97,45 +46,11 @@ export interface UserGame { achievementsPointsEarnedSum: number; } -export interface DownloadQueue { - id: number; - createdAt: Date; - updatedAt: Date; -} - -/* Used by the library */ -export interface Game { - id: number; - title: string; - iconUrl: string; - status: GameStatus | null; - folderName: string; - downloadPath: string | null; - progress: number; - bytesDownloaded: number; - playTimeInMilliseconds: number; - downloader: Downloader; - winePrefixPath: string | null; - executablePath: string | null; - launchOptions: string | null; - lastTimePlayed: Date | null; - uri: string | null; - fileSize: number; - objectID: string; - shop: GameShop; - downloadQueue: DownloadQueue | null; - shouldSeed: boolean; - createdAt: Date; - updatedAt: Date; -} - -export type LibraryGame = Omit; - export interface GameRunning { - id?: number; + id: string; title: string; iconUrl: string | null; - objectID: string; + objectId: string; shop: GameShop; sessionDurationInMillis: number; } @@ -251,16 +166,6 @@ export interface UserProfileCurrentGame extends Omit { export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS"; -export type SubscriptionStatus = "active" | "pending" | "cancelled"; - -export interface Subscription { - id: string; - status: SubscriptionStatus; - plan: { id: string; name: string }; - expiresAt: string | null; - paymentMethod: "pix" | "paypal"; -} - export interface UserDetails { id: string; username: string; @@ -270,6 +175,7 @@ export interface UserDetails { backgroundImageUrl: string | null; profileVisibility: ProfileVisibility; bio: string; + featurebaseJwt: string; subscription: Subscription | null; quirks?: { backupsPerGameLimit: number; @@ -302,6 +208,7 @@ export interface UpdateProfileRequest { profileImageUrl?: string | null; backgroundImageUrl?: string | null; bio?: string; + language?: string; } export interface DownloadSourceDownload { @@ -356,11 +263,6 @@ export interface UserStats { unlockedAchievementSum?: number; } -export interface UnlockedAchievement { - name: string; - unlockTime: number; -} - export interface AchievementFile { type: Cracker; filePath: string; @@ -419,8 +321,14 @@ export interface CatalogueSearchPayload { developers: string[]; } +export interface LibraryGame extends Game { + id: string; + download: Download | null; +} + +export * from "./game.types"; export * from "./steam.types"; -export * from "./real-debrid.types"; +export * from "./download.types"; export * from "./ludusavi.types"; export * from "./how-long-to-beat.types"; -export * from "./torbox.types"; +export * from "./level.types"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts new file mode 100644 index 00000000..06fc79e3 --- /dev/null +++ b/src/types/level.types.ts @@ -0,0 +1,81 @@ +import type { Downloader } from "@shared"; +import type { + GameShop, + SteamAchievement, + UnlockedAchievement, +} from "./game.types"; +import type { DownloadStatus } from "./download.types"; + +export type SubscriptionStatus = "active" | "pending" | "cancelled"; + +export interface Subscription { + id: string; + status: SubscriptionStatus; + plan: { id: string; name: string }; + expiresAt: string | null; + paymentMethod: "pix" | "paypal"; +} + +export interface Auth { + accessToken: string; + refreshToken: string; + tokenExpirationTimestamp: number; +} + +export interface User { + id: string; + displayName: string; + profileImageUrl: string | null; + backgroundImageUrl: string | null; + subscription: Subscription | null; +} + +export interface Game { + title: string; + iconUrl: string | null; + playTimeInMilliseconds: number; + lastTimePlayed: Date | null; + objectId: string; + shop: GameShop; + remoteId: string | null; + isDeleted: boolean; + winePrefixPath?: string | null; + executablePath?: string | null; + launchOptions?: string | null; +} + +export interface Download { + shop: GameShop; + objectId: string; + uri: string; + folderName: string | null; + downloadPath: string; + progress: number; + downloader: Downloader; + bytesDownloaded: number; + fileSize: number | null; + shouldSeed: boolean; + status: DownloadStatus | null; + queued: boolean; + timestamp: number; +} + +export interface GameAchievement { + achievements: SteamAchievement[]; + unlockedAchievements: UnlockedAchievement[]; +} + +export interface UserPreferences { + downloadsPath: string | null; + language: string; + realDebridApiToken: string | null; + preferQuitInsteadOfHiding: boolean; + runAtStartup: boolean; + startMinimized: boolean; + disableNsfwAlert: boolean; + seedAfterDownloadComplete: boolean; + showHiddenAchievementsDescription: boolean; + downloadNotificationsEnabled: boolean; + repackUpdatesNotificationsEnabled: boolean; + achievementNotificationsEnabled: boolean; +} diff --git a/src/types/real-debrid.types.ts b/src/types/real-debrid.types.ts deleted file mode 100644 index 6b16ecfd..00000000 --- a/src/types/real-debrid.types.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface RealDebridUnrestrictLink { - id: string; - filename: string; - mimeType: string; - filesize: number; - link: string; - host: string; - host_icon: string; - chunks: number; - crc: number; - download: string; - streamable: number; -} - -export interface RealDebridAddMagnet { - id: string; - // URL of the created resource - uri: string; -} - -export interface RealDebridTorrentInfo { - id: string; - filename: string; - original_filename: string; - hash: string; - bytes: number; - original_bytes: number; - host: string; - split: number; - progress: number; - status: - | "magnet_error" - | "magnet_conversion" - | "waiting_files_selection" - | "queued" - | "downloading" - | "downloaded" - | "error" - | "virus" - | "compressing" - | "uploading" - | "dead"; - added: string; - files: { - id: number; - path: string; - bytes: number; - selected: number; - }[]; - links: string[]; - ended: string; - speed: number; - seeders: number; -} - -export interface RealDebridUser { - id: number; - username: string; - email: string; - points: number; - locale: string; - avatar: string; - type: string; - premium: number; - expiration: string; -} diff --git a/yarn.lock b/yarn.lock index 69ee75d8..3a4656b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3026,11 +3026,6 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" -"@sqltools/formatter@^1.2.5": - version "1.2.5" - resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz" - integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw== - "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" @@ -3699,6 +3694,18 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +abstract-level@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/abstract-level/-/abstract-level-2.0.2.tgz#8d965e731afb42a72f163874410c1687fb2e4bdb" + integrity sha512-pPJixmXk/kTKLB2sSue7o4Uj6TlLD2XfaP2gWZomHVCC6cuUGX/VslQqKG1yZHfXwBb/3lS6oSTMPGzh1P1iig== + dependencies: + buffer "^6.0.3" + is-buffer "^2.0.5" + level-supports "^6.0.0" + level-transcoder "^1.0.1" + maybe-combine-errors "^1.0.0" + module-error "^1.0.1" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" @@ -3809,11 +3816,6 @@ ansi-styles@^6.1.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -any-promise@^1.0.0: - version "1.3.0" - resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" - integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== - anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -3865,11 +3867,6 @@ app-builder-lib@25.1.8: tar "^6.1.12" temp-file "^3.4.0" -app-root-path@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz" - integrity sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA== - applescript@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz" @@ -4421,6 +4418,16 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +classic-level@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/classic-level/-/classic-level-2.0.0.tgz#6fd9ca686bbcd645e35caaf403c3f3a56495d11b" + integrity sha512-ftiMvKgCQK+OppXcvMieDoYlYLYWhScK6yZRFBrrlHQRbm4k6Gr+yDgu/wt3V0k1/jtNbuiXAsRmuAFcD0Tx5Q== + dependencies: + abstract-level "^2.0.0" + module-error "^1.0.1" + napi-macros "^2.2.2" + node-gyp-build "^4.3.0" + classnames@^2.2.1, classnames@^2.2.6, classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" @@ -4438,18 +4445,6 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-highlight@^2.1.11: - version "2.1.11" - resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" - integrity sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg== - dependencies: - chalk "^4.0.0" - highlight.js "^10.7.1" - mz "^2.4.0" - parse5 "^5.1.1" - parse5-htmlparser2-tree-adapter "^6.0.0" - yargs "^16.0.0" - cli-spinners@^2.5.0: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" @@ -4463,15 +4458,6 @@ cli-truncate@^2.1.0: slice-ansi "^3.0.0" string-width "^4.2.0" -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -4780,11 +4766,6 @@ date-fns@^3.6.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== -dayjs@^1.11.9: - version "1.11.13" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== - debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -4988,16 +4969,16 @@ dotenv-expand@^11.0.6: dependencies: dotenv "^16.4.4" -dotenv@^16.0.3, dotenv@^16.4.4, dotenv@^16.4.5: - version "16.4.5" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" - integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== - dotenv@^16.3.1: version "16.4.7" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== +dotenv@^16.4.4, dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dunder-proto@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.0.tgz#c2fce098b3c8f8899554905f4377b6d85dabaa80" @@ -5975,17 +5956,6 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^10.3.10: - version "10.3.15" - resolved "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz" - integrity sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw== - dependencies: - foreground-child "^3.1.0" - jackspeak "^2.3.6" - minimatch "^9.0.1" - minipass "^7.0.4" - path-scurry "^1.11.0" - glob@^10.3.12, glob@^10.3.7: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -6177,11 +6147,6 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -highlight.js@^10.7.1: - version "10.7.3" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" - integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== - hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6353,7 +6318,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6459,6 +6424,11 @@ is-boolean-object@^1.2.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" +is-buffer@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -6723,15 +6693,6 @@ iterator.prototype@^1.1.3: reflect.getprototypeof "^1.0.8" set-function-name "^2.0.2" -jackspeak@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.0" - jackspeak@^3.1.2: version "3.4.3" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" @@ -6958,6 +6919,19 @@ lazy-val@^1.0.5: resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.5.tgz#6cf3b9f5bc31cee7ee3e369c0832b7583dcd923d" integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== +level-supports@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/level-supports/-/level-supports-6.2.0.tgz#e78b228973a24acdc5199c5f51e244e70f26c611" + integrity sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w== + +level-transcoder@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/level-transcoder/-/level-transcoder-1.0.1.tgz#f8cef5990c4f1283d4c86d949e73631b0bc8ba9c" + integrity sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w== + dependencies: + buffer "^6.0.3" + module-error "^1.0.1" + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -7198,6 +7172,11 @@ math-intrinsics@^1.0.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.0.0.tgz#4e04bf87c85aa51e90d078dac2252b4eb5260817" integrity sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA== +maybe-combine-errors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz#e9592832e61fc47643a92cff3c1f33e27211e5be" + integrity sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A== + media-query-parser@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29" @@ -7283,7 +7262,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.3, minimatch@^9.0.4: +minimatch@^9.0.3, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -7389,11 +7368,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^2.1.3: - version "2.1.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" - integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== - mkdirp@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" @@ -7414,6 +7388,11 @@ modern-ahocorasick@^1.0.0: resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.0.1.tgz#dec373444f51b5458ac05216a8ec376e126dd283" integrity sha512-yoe+JbhTClckZ67b2itRtistFKf8yPYelHLc7e5xAwtNAXxM6wJTUx2C7QeVSJFDzKT7bCIFyBVybPMKvmB9AA== +module-error@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/module-error/-/module-error-1.0.2.tgz#8d1a48897ca883f47a45816d4fb3e3c6ba404d86" + integrity sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -7424,15 +7403,6 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mz@^2.4.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" - integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== - dependencies: - any-promise "^1.0.0" - object-assign "^4.0.1" - thenify-all "^1.0.0" - nan@^2.18.0: version "2.22.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3" @@ -7448,6 +7418,11 @@ napi-build-utils@^1.0.1: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== +napi-macros@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.2.2.tgz#817fef20c3e0e40a963fbf7b37d1600bd0201044" + integrity sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -7506,6 +7481,11 @@ node-fetch@^3.3.0: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" +node-gyp-build@^4.3.0: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-gyp@^9.0.0: version "9.4.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.4.1.tgz#8a1023e0d6766ecb52764cc3a734b36ff275e185" @@ -7565,7 +7545,7 @@ nwsapi@^2.2.12: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.13.tgz#e56b4e98960e7a040e5474536587e599c4ff4655" integrity sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ== -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -7738,23 +7718,6 @@ parse-torrent@^11.0.17: queue-microtask "^1.2.3" uint8-util "^2.2.5" -parse5-htmlparser2-tree-adapter@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" - integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== - dependencies: - parse5 "^6.0.1" - -parse5@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" - integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== - -parse5@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== - parse5@^7.0.0, parse5@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" @@ -7787,7 +7750,7 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.11.0, path-scurry@^1.11.1, path-scurry@^1.6.1: +path-scurry@^1.11.1, path-scurry@^1.6.1: version "1.11.1" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -8156,11 +8119,6 @@ redux@^5.0.1: resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== -reflect-metadata@^0.2.1: - version "0.2.2" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" - integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== - reflect.getprototypeof@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz#c58afb17a4007b4d1118c07b92c23fca422c5d82" @@ -8612,14 +8570,6 @@ set-function-name@^2.0.1, set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" -sha.js@^2.4.11: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -9034,20 +8984,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -thenify-all@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" - integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== - dependencies: - thenify ">= 3.1.0 < 4" - -"thenify@>= 3.1.0 < 4": - version "3.3.1" - resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" - integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== - dependencies: - any-promise "^1.0.0" - "through@>=2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -9168,7 +9104,7 @@ tslib@^2.0.0, tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tslib@^2.0.3, tslib@^2.5.0, tslib@^2.6.2: +tslib@^2.0.3, tslib@^2.6.2: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== @@ -9246,27 +9182,6 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" -typeorm@^0.3.20: - version "0.3.20" - resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.20.tgz#4b61d737c6fed4e9f63006f88d58a5e54816b7ab" - integrity sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q== - dependencies: - "@sqltools/formatter" "^1.2.5" - app-root-path "^3.1.0" - buffer "^6.0.3" - chalk "^4.1.2" - cli-highlight "^2.1.11" - dayjs "^1.11.9" - debug "^4.3.4" - dotenv "^16.0.3" - glob "^10.3.10" - mkdirp "^2.1.3" - reflect-metadata "^0.2.1" - sha.js "^2.4.11" - tslib "^2.5.0" - uuid "^9.0.0" - yargs "^17.6.2" - typescript@^5.3.3, typescript@^5.4.3: version "5.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" @@ -9413,7 +9328,7 @@ util-deprecate@^1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@^9.0.0, uuid@^9.0.1: +uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -9714,29 +9629,11 @@ yaml@^2.6.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773" integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg== -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^16.0.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - yargs@^17.0.0, yargs@^17.0.1, yargs@^17.6.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"