Merge branch 'feat/migration-to-leveldb' into feature/torbox-integration

# Conflicts:
#	src/locales/en/translation.json
#	src/locales/pt-BR/translation.json
#	src/main/entity/user-preferences.entity.ts
#	src/main/events/auth/sign-out.ts
#	src/main/knex-client.ts
#	src/main/main.ts
#	src/main/services/download/download-manager.ts
#	src/main/services/process-watcher.ts
#	src/renderer/src/pages/downloads/download-group.tsx
#	src/types/index.ts
#	src/types/torbox.types.ts
This commit is contained in:
Zamitto 2025-02-01 15:43:32 -03:00
commit 3d571edccb
161 changed files with 2590 additions and 2802 deletions

View file

@ -125,6 +125,10 @@ cd hydra
yarn yarn
``` ```
### <a name="install-openssl-11"></a> Instale OpenSSL 1.1
[OpenSSL 1.1](https://slproweb.com/download/Win64OpenSSL-1_1_1w.exe) é exigido pelo libtorrent em ambientes Windows.
### <a name="install-python-39"></a> Instale Python 3.9 ### <a name="install-python-39"></a> 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/). 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/).

View file

@ -38,6 +38,13 @@ export default defineConfig(({ mode }) => {
build: { build: {
sourcemap: true, sourcemap: true,
}, },
css: {
preprocessorOptions: {
scss: {
api: "modern",
},
},
},
resolve: { resolve: {
alias: { alias: {
"@renderer": resolve("src/renderer/src"), "@renderer": resolve("src/renderer/src"),

View file

@ -47,6 +47,7 @@
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"classic-level": "^2.0.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"color": "^4.2.3", "color": "^4.2.3",
"color.js": "^1.2.0", "color.js": "^1.2.0",
@ -74,7 +75,6 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.4.3",
"typeorm": "^0.3.20",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0", "yup": "^1.5.0",

View file

@ -94,7 +94,7 @@ def seed_status():
@app.route("/healthcheck", methods=["GET"]) @app.route("/healthcheck", methods=["GET"])
def healthcheck(): def healthcheck():
return "", 200 return "ok", 200
@app.route("/process-list", methods=["GET"]) @app.route("/process-list", methods=["GET"])
def process_list(): def process_list():

View file

@ -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, { await fetch(process.env.BUILD_WEBHOOK_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
uploads, upload,
branchName: process.env.BRANCH_NAME, branchName: process.env.BRANCH_NAME,
version: packageJson.version, version: packageJson.version,
githubActor: process.env.GITHUB_ACTOR, githubActor: process.env.GITHUB_ACTOR,

View file

@ -281,7 +281,23 @@
"disable_nsfw_alert": "Disable NSFW alert", "disable_nsfw_alert": "Disable NSFW alert",
"seed_after_download_complete": "Seed after download complete", "seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them", "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": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",

View file

@ -172,7 +172,8 @@
"reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}", "reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}",
"reset_achievements_title": "Tem certeza?", "reset_achievements_title": "Tem certeza?",
"reset_achievements_success": "Conquistas resetadas com sucesso", "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": { "activation": {
"title": "Ativação", "title": "Ativação",
@ -269,7 +270,23 @@
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado", "disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download", "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", "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": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@ -398,7 +415,7 @@
"new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos", "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas", "achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas",
"achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}", "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", "achievement_earn_points": "Ganhe {{points}} pontos com essa conquista",
"earned_points": "Pontos ganhos:", "earned_points": "Pontos ganhos:",
"available_points": "Pontos disponíveis:", "available_points": "Pontos disponíveis:",

View file

@ -7,8 +7,8 @@
"featured": "Рекомендации", "featured": "Рекомендации",
"surprise_me": "Удиви меня", "surprise_me": "Удиви меня",
"no_results": "Ничего не найдено", "no_results": "Ничего не найдено",
"hot": "Сейчас в топе", "hot": "Сейчас популярно",
"start_typing": "Начинаю вводить текст для поиска...", "start_typing": "Начинаю вводить текст...",
"weekly": "📅 Лучшие игры недели", "weekly": "📅 Лучшие игры недели",
"achievements": "🏆 Игры, в которых нужно победить" "achievements": "🏆 Игры, в которых нужно победить"
}, },
@ -278,7 +278,23 @@
"source_already_exists": "Этот источник уже добавлен", "source_already_exists": "Этот источник уже добавлен",
"user_unblocked": "Пользователь разблокирован", "user_unblocked": "Пользователь разблокирован",
"seed_after_download_complete": "Раздавать после завершения загрузки", "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": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",
@ -408,7 +424,7 @@
"subscribe_now": "Подпишитесь прямо сейчас", "subscribe_now": "Подпишитесь прямо сейчас",
"cloud_saving": "Сохранение в облаке", "cloud_saving": "Сохранение в облаке",
"cloud_achievements": "Сохраняйте свои достижения в облаке", "cloud_achievements": "Сохраняйте свои достижения в облаке",
"animated_profile_picture": "Анимированные фотографии профиля", "animated_profile_picture": "Анимированные аватарки",
"premium_support": "Премиальная поддержка", "premium_support": "Премиальная поддержка",
"show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей", "show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей",
"animated_profile_banner": "Анимированный баннер профиля", "animated_profile_banner": "Анимированный баннер профиля",

View file

@ -7,13 +7,18 @@ export const defaultDownloadsPath = app.getPath("downloads");
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); 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 databaseDirectory = path.join(app.getPath("appData"), "hydra");
export const databasePath = path.join( export const databasePath = path.join(
databaseDirectory, databaseDirectory,
isStaging ? "hydra_test.db" : "hydra.db" 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 export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds") ? path.join(process.resourcesPath, "seeds")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,19 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event"; 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 getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await userAuthRepository.findOne({ where: { id: 1 } }); const auth = await db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
if (!auth) return null; 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; if (!payload) return null;

View file

@ -1,7 +1,24 @@
import i18next from "i18next";
import { registerEvent } from "../register-event"; 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) => const openAuthWindow = async (
WindowManager.openAuthWindow(); _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); registerEvent("openAuthWindow", openAuthWindow);

View file

@ -1,26 +1,25 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source"; import { PythonRPC } from "@main/services/python-rpc";
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => { const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
const databaseOperations = dataSource const databaseOperations = db
.transaction(async (transactionalEntityManager) => { .batch([
await transactionalEntityManager.getRepository(DownloadQueue).delete({}); {
type: "del",
await transactionalEntityManager.getRepository(Game).delete({}); key: levelKeys.auth,
},
await transactionalEntityManager {
.getRepository(UserAuth) type: "del",
.delete({ id: 1 }); key: levelKeys.user,
},
await transactionalEntityManager ])
.getRepository(UserSubscription)
.delete({ id: 1 });
})
.then(() => { .then(() => {
/* Removes all games being played */ /* Removes all games being played */
gamesPlaytime.clear(); gamesPlaytime.clear();
return Promise.all([gamesSublevel.clear(), downloadsSublevel.clear()]);
}); });
/* Cancels any ongoing downloads */ /* Cancels any ongoing downloads */

View file

@ -1,47 +1,8 @@
import type { AppUpdaterEvent } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater"; import { UpdateManager } from "@main/services/update-manager";
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: "" };
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater return UpdateManager.checkForUpdates();
.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;
}; };
registerEvent("checkForUpdates", checkForUpdates); registerEvent("checkForUpdates", checkForUpdates);

View file

@ -1,10 +1,10 @@
import { gameShopCacheRepository } from "@main/repository"; import { getSteamAppDetails, logger } from "@main/services";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; import type { ShopDetails, GameShop } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { gamesShopCacheSublevel, levelKeys } from "@main/level";
const getLocalizedSteamAppDetails = async ( const getLocalizedSteamAppDetails = async (
objectId: string, objectId: string,
@ -39,35 +39,27 @@ const getGameShopDetails = async (
language: string language: string
): Promise<ShopDetails | null> => { ): Promise<ShopDetails | null> => {
if (shop === "steam") { if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({ const cachedData = await gamesShopCacheSublevel.get(
where: { objectID: objectId, language }, levelKeys.gameShopCacheItem(shop, objectId, language)
}); );
const appDetails = getLocalizedSteamAppDetails(objectId, language).then( const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => { (result) => {
if (result) { if (result) {
gameShopCacheRepository.upsert( gamesShopCacheSublevel
{ .put(levelKeys.gameShopCacheItem(shop, objectId, language), result)
objectID: objectId, .catch((err) => {
shop: "steam", logger.error("Could not cache game details", err);
language, });
serializedData: JSON.stringify(result),
},
["objectID"]
);
} }
return result; return result;
} }
); );
const cachedGame = cachedData?.serializedData if (cachedData) {
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
: null;
if (cachedGame) {
return { return {
...cachedGame, ...cachedData,
objectId, objectId,
} as ShopDetails; } as ShopDetails;
} }

View file

@ -1,14 +1,14 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { userPreferencesRepository } from "@main/repository";
import type { TrendingGame } from "@types"; import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const userPreferences = await userPreferencesRepository.findOne({ const language = await db
where: { id: 1 }, .get<string, string>(levelKeys.language, {
}); valueEncoding: "utf-8",
})
const language = userPreferences?.language || "en"; .then((language) => language || "en");
const trendingGames = await HydraApi.get<TrendingGame[]>( const trendingGames = await HydraApi.get<TrendingGame[]>(
"/games/trending", "/games/trending",

View file

@ -1,19 +1,14 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { Ludusavi } from "@main/services"; import { Ludusavi } from "@main/services";
import { gameRepository } from "@main/repository"; import { gamesSublevel, levelKeys } from "@main/level";
const getGameBackupPreview = async ( const getGameBackupPreview = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => { ) => {
const game = await gameRepository.findOne({ const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
where: {
objectID: objectId,
shop,
},
});
return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath); return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath);
}; };

View file

@ -10,7 +10,7 @@ import os from "node:os";
import { backupsPath } from "@main/constants"; import { backupsPath } from "@main/constants";
import { app } from "electron"; import { app } from "electron";
import { normalizePath } from "@main/helpers"; import { normalizePath } from "@main/helpers";
import { gameRepository } from "@main/repository"; import { gamesSublevel, levelKeys } from "@main/level";
const bundleBackup = async ( const bundleBackup = async (
shop: GameShop, shop: GameShop,
@ -45,12 +45,7 @@ export const createBackup = async (
shop: GameShop, shop: GameShop,
downloadOptionTitle: string | null downloadOptionTitle: string | null
) => { ) => {
const game = await gameRepository.findOne({ const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
where: {
objectID: objectId,
shop,
},
});
const bundleLocation = await bundleBackup( const bundleLocation = await bundleBackup(
shop, shop,

View file

@ -1,15 +1,21 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const checkFolderWritePermission = async ( const checkFolderWritePermission = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
path: string testPath: string
) => ) => {
new Promise((resolve) => { const testFilePath = path.join(testPath, ".hydra-write-test");
fs.access(path, fs.constants.W_OK, (err) => {
resolve(!err); try {
}); fs.writeFileSync(testFilePath, "");
}); fs.rmSync(testFilePath);
return true;
} catch (err) {
return false;
}
};
registerEvent("checkFolderWritePermission", checkFolderWritePermission); registerEvent("checkFolderWritePermission", checkFolderWritePermission);

View file

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

View file

@ -1,12 +1,14 @@
import { userPreferencesRepository } from "@main/repository";
import { defaultDownloadsPath } from "@main/constants"; import { defaultDownloadsPath } from "@main/constants";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
export const getDownloadsPath = async () => { export const getDownloadsPath = async () => {
const userPreferences = await userPreferencesRepository.findOne({ const userPreferences = await db.get<string, UserPreferences>(
where: { levelKeys.userPreferences,
id: 1, {
}, valueEncoding: "json",
}); }
);
if (userPreferences && userPreferences.downloadsPath) if (userPreferences && userPreferences.downloadsPath)
return userPreferences.downloadsPath; return userPreferences.downloadsPath;

View file

@ -0,0 +1,7 @@
export const parseLaunchOptions = (params?: string | null): string[] => {
if (!params) {
return [];
}
return params.split(" ");
};

View file

@ -1,57 +1,55 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { Game, GameShop } from "@types";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements"; import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string, objectId: string,
title: string, title: string
shop: GameShop
) => { ) => {
return gameRepository const gameKey = levelKeys.game(shop, objectId);
.update( const game = await gamesSublevel.get(gameKey);
{
objectID: objectId,
},
{
shop,
status: null,
isDeleted: false,
}
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon if (game) {
? steamUrlBuilder.icon(objectId, steamGame.clientIcon) await downloadsSublevel.del(gameKey);
: null;
await gameRepository.insert({ await gamesSublevel.put(gameKey, {
title, ...game,
iconUrl, isDeleted: false,
objectID: objectId,
shop,
});
}
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
}); });
} 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); registerEvent("addGameToLibrary", addGameToLibrary);

View file

@ -1,10 +1,11 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { logger } from "@main/services"; import { logger } from "@main/services";
import sudo from "sudo-prompt"; import sudo from "sudo-prompt";
import { app } from "electron"; import { app } from "electron";
import { PythonRPC } from "@main/services/python-rpc"; import { PythonRPC } from "@main/services/python-rpc";
import { ProcessPayload } from "@main/services/download/types"; import { ProcessPayload } from "@main/services/download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const getKillCommand = (pid: number) => { const getKillCommand = (pid: number) => {
if (process.platform == "win32") { if (process.platform == "win32") {
@ -16,15 +17,14 @@ const getKillCommand = (pid: number) => {
const closeGame = async ( const closeGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const processes = const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data || (await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[]; [];
const game = await gameRepository.findOne({ const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
where: { id: gameId, isDeleted: false },
});
if (!game) return; if (!game) return;

View file

@ -1,18 +1,18 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts"; import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { removeSymbolsFromName } from "@shared"; import { removeSymbolsFromName } from "@shared";
import { GameShop } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
const createGameShortcut = async ( const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
id: number shop: GameShop,
objectId: string
): Promise<boolean> => { ): Promise<boolean> => {
const game = await gameRepository.findOne({ const gameKey = levelKeys.game(shop, objectId);
where: { id, executablePath: Not(IsNull()) }, const game = await gamesSublevel.get(gameKey);
});
if (game) { if (game) {
const filePath = game.executablePath; const filePath = game.executablePath;

View file

@ -1,37 +1,27 @@
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path"; import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services"; import { logger } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
const deleteGameFolder = async ( const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
): Promise<void> => { ): Promise<void> => {
const game = await gameRepository.findOne({ const downloadKey = levelKeys.game(shop, objectId);
where: [
{
id: gameId,
isDeleted: false,
status: "removed",
},
{
id: gameId,
progress: 1,
isDeleted: false,
},
],
});
if (!game) return; const download = await downloadsSublevel.get(downloadKey);
if (game.folderName) { if (!download) return;
if (download.folderName) {
const folderPath = path.join( const folderPath = path.join(
game.downloadPath ?? (await getDownloadsPath()), download.downloadPath ?? (await getDownloadsPath()),
game.folderName download.folderName
); );
if (fs.existsSync(folderPath)) { if (fs.existsSync(folderPath)) {
@ -52,10 +42,7 @@ const deleteGameFolder = async (
} }
} }
await gameRepository.update( await downloadsSublevel.del(downloadKey);
{ id: gameId },
{ downloadPath: null, folderName: null, status: null, progress: 0 }
);
}; };
registerEvent("deleteGameFolder", deleteGameFolder); registerEvent("deleteGameFolder", deleteGameFolder);

View file

@ -1,16 +1,21 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const getGameByObjectId = async ( const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string objectId: string
) => ) => {
gameRepository.findOne({ const gameKey = levelKeys.game(shop, objectId);
where: { const [game, download] = await Promise.all([
objectID: objectId, gamesSublevel.get(gameKey),
isDeleted: false, downloadsSublevel.get(gameKey),
}, ]);
});
if (!game || game.isDeleted) return null;
return { id: gameKey, ...game, download };
};
registerEvent("getGameByObjectId", getGameByObjectId); registerEvent("getGameByObjectId", getGameByObjectId);

View file

@ -1,17 +1,26 @@
import { gameRepository } from "@main/repository"; import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, gamesSublevel } from "@main/level";
const getLibrary = async () => const getLibrary = async (): Promise<LibraryGame[]> => {
gameRepository.find({ return gamesSublevel
where: { .iterator()
isDeleted: false, .all()
}, .then((results) => {
relations: { return Promise.all(
downloadQueue: true, results
}, .filter(([_key, game]) => game.isDeleted === false)
order: { .map(async ([key, game]) => {
createdAt: "desc", const download = await downloadsSublevel.get(key);
},
}); return {
id: key,
...game,
download: download ?? null,
};
})
);
});
};
registerEvent("getLibrary", getLibrary); registerEvent("getLibrary", getLibrary);

View file

@ -1,14 +1,14 @@
import { shell } from "electron"; import { shell } from "electron";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const openGameExecutablePath = async ( const openGameExecutablePath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const game = await gameRepository.findOne({ const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
where: { id: gameId, isDeleted: false },
});
if (!game || !game.executablePath) return; if (!game || !game.executablePath) return;

View file

@ -1,22 +1,22 @@
import { shell } from "electron"; import { shell } from "electron";
import path from "node:path"; import path from "node:path";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path"; import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
const openGameInstallerPath = async ( const openGameInstallerPath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const game = await gameRepository.findOne({ const download = await downloadsSublevel.get(levelKeys.game(shop, objectId));
where: { id: gameId, isDeleted: false },
});
if (!game || !game.folderName || !game.downloadPath) return true; if (!download || !download.folderName || !download.downloadPath) return true;
const gamePath = path.join( const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()), download.downloadPath ?? (await getDownloadsPath()),
game.folderName! download.folderName!
); );
shell.showItemInFolder(gamePath); shell.showItemInFolder(gamePath);

View file

@ -1,14 +1,12 @@
import { shell } from "electron"; import { shell } from "electron";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { writeFile } from "node:fs/promises";
import { spawnSync, exec } from "node:child_process"; 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 { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const executeGameInstaller = (filePath: string) => { const executeGameInstaller = (filePath: string) => {
if (process.platform === "win32") { if (process.platform === "win32") {
@ -26,21 +24,21 @@ const executeGameInstaller = (filePath: string) => {
const openGameInstaller = async ( const openGameInstaller = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const game = await gameRepository.findOne({ const downloadKey = levelKeys.game(shop, objectId);
where: { id: gameId, isDeleted: false }, const download = await downloadsSublevel.get(downloadKey);
});
if (!game || !game.folderName) return true; if (!download?.folderName) return true;
const gamePath = path.join( const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()), download.downloadPath ?? (await getDownloadsPath()),
game.folderName! download.folderName
); );
if (!fs.existsSync(gamePath)) { if (!fs.existsSync(gamePath)) {
await gameRepository.update({ id: gameId }, { status: null }); await downloadsSublevel.del(downloadKey);
return true; 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); shell.openPath(gamePath);
return true; return true;
}; };

View file

@ -1,24 +1,39 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { shell } from "electron"; import { shell } from "electron";
import { spawn } from "child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path"; 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 ( const openGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number, shop: GameShop,
objectId: string,
executablePath: string, executablePath: string,
launchOptions: string | null launchOptions?: string | null
) => { ) => {
// TODO: revisit this for launchOptions
const parsedPath = parseExecutablePath(executablePath); const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
await gameRepository.update( const gameKey = levelKeys.game(shop, objectId);
{ id: gameId },
{ executablePath: parsedPath, launchOptions }
);
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); registerEvent("openGame", openGame);

View file

@ -1,26 +1,26 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository"; import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services"; import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const removeGameFromLibrary = async ( const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
gameRepository.update( const gameKey = levelKeys.game(shop, objectId);
{ id: gameId }, const game = await gamesSublevel.get(gameKey);
{ isDeleted: true, executablePath: null }
);
removeRemoveGameFromLibrary(gameId).catch((err) => { if (game) {
logger.error("removeRemoveGameFromLibrary", err); await gamesSublevel.put(gameKey, {
}); ...game,
}; isDeleted: true,
executablePath: null,
});
const removeRemoveGameFromLibrary = async (gameId: number) => { if (game?.remoteId) {
const game = await gameRepository.findOne({ where: { id: gameId } }); HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}
if (game?.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} }
}; };

View file

@ -1,21 +1,14 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository"; import { levelKeys, downloadsSublevel } from "@main/level";
import { GameShop } from "@types";
const removeGame = async ( const removeGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
await gameRepository.update( const downloadKey = levelKeys.game(shop, objectId);
{ await downloadsSublevel.del(downloadKey);
id: gameId,
},
{
status: "removed",
downloadPath: null,
bytesDownloaded: 0,
progress: 0,
}
);
}; };
registerEvent("removeGame", removeGame); registerEvent("removeGame", removeGame);

View file

@ -1,16 +1,22 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { findAchievementFiles } from "@main/services/achievements/find-achivement-files"; import { findAchievementFiles } from "@main/services/achievements/find-achivement-files";
import fs from "fs"; import fs from "fs";
import { achievementsLogger, HydraApi, WindowManager } from "@main/services"; import { achievementsLogger, HydraApi, WindowManager } from "@main/services";
import { getUnlockedAchievements } from "../user/get-unlocked-achievements"; import { getUnlockedAchievements } from "../user/get-unlocked-achievements";
import {
gameAchievementsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
import type { GameShop } from "@types";
const resetGameAchievements = async ( const resetGameAchievements = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
try { try {
const game = await gameRepository.findOne({ where: { id: gameId } }); const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
if (!game) return; if (!game) return;
@ -23,28 +29,34 @@ const resetGameAchievements = async (
} }
} }
await gameAchievementRepository.update( const levelKey = levelKeys.game(game.shop, game.objectId);
{ objectId: game.objectID },
{ await gameAchievementsSublevel
unlockedAchievements: null, .get(levelKey)
} .then(async (gameAchievements) => {
); if (gameAchievements) {
await gameAchievementsSublevel.put(levelKey, {
...gameAchievements,
unlockedAchievements: [],
});
}
});
await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then( await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then(
() => () =>
achievementsLogger.log( achievementsLogger.log(
`Deleted achievements from ${game.remoteId} - ${game.objectID} - ${game.title}` `Deleted achievements from ${game.remoteId} - ${game.objectId} - ${game.title}`
) )
); );
const gameAchievements = await getUnlockedAchievements( const gameAchievements = await getUnlockedAchievements(
game.objectID, game.objectId,
game.shop, game.shop,
true true
); );
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${game.objectID}-${game.shop}`, `on-update-achievements-${game.objectId}-${game.shop}`,
gameAchievements gameAchievements
); );
} catch (error) { } catch (error) {

View file

@ -1,13 +1,23 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { levelKeys, gamesSublevel } from "@main/level";
import type { GameShop } from "@types";
const selectGameWinePrefix = async ( const selectGameWinePrefix = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
id: number, shop: GameShop,
objectId: string,
winePrefixPath: string | null 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); registerEvent("selectGameWinePrefix", selectGameWinePrefix);

View file

@ -1,25 +1,27 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { parseExecutablePath } from "../helpers/parse-executable-path"; import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const updateExecutablePath = async ( const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
id: number, shop: GameShop,
objectId: string,
executablePath: string | null executablePath: string | null
) => { ) => {
const parsedPath = executablePath const parsedPath = executablePath
? parseExecutablePath(executablePath) ? parseExecutablePath(executablePath)
: null; : null;
return gameRepository.update( const gameKey = levelKeys.game(shop, objectId);
{
id, const game = await gamesSublevel.get(gameKey);
}, if (!game) return;
{
executablePath: parsedPath, await gamesSublevel.put(gameKey, {
} ...game,
); executablePath: parsedPath,
});
}; };
registerEvent("updateExecutablePath", updateExecutablePath); registerEvent("updateExecutablePath", updateExecutablePath);

View file

@ -1,19 +1,23 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const updateLaunchOptions = async ( const updateLaunchOptions = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
id: number, shop: GameShop,
objectId: string,
launchOptions: string | null launchOptions: string | null
) => { ) => {
return gameRepository.update( const gameKey = levelKeys.game(shop, objectId);
{
id, const game = await gamesSublevel.get(gameKey);
},
{ if (game) {
await gamesSublevel.put(gameKey, {
...game,
launchOptions: launchOptions?.trim() != "" ? launchOptions : null, launchOptions: launchOptions?.trim() != "" ? launchOptions : null,
} });
); }
}; };
registerEvent("updateLaunchOptions", updateLaunchOptions); registerEvent("updateLaunchOptions", updateLaunchOptions);

View file

@ -1,13 +1,17 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel } from "@main/level";
const verifyExecutablePathInUse = async ( const verifyExecutablePathInUse = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
executablePath: string executablePath: string
) => { ) => {
return gameRepository.findOne({ for await (const game of gamesSublevel.values()) {
where: { executablePath }, if (game.executablePath === executablePath) {
}); return true;
}
}
return false;
}; };
registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse); registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse);

View file

@ -1,17 +1,20 @@
import { shell } from "electron"; import { shell } from "electron";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { userAuthRepository } from "@main/repository"; import { Crypto, HydraApi } from "@main/services";
import { HydraApi } from "@main/services"; import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
const userAuth = await userAuthRepository.findOne({ where: { id: 1 } }); const auth = await db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
if (!userAuth) { if (!auth) {
return; return;
} }
const paymentToken = await HydraApi.post("/auth/payment", { const paymentToken = await HydraApi.post("/auth/payment", {
refreshToken: userAuth.refreshToken, refreshToken: Crypto.decrypt(auth.refreshToken),
}).then((response) => response.accessToken); }).then((response) => response.accessToken);
const params = new URLSearchParams({ const params = new URLSearchParams({

View file

@ -1,7 +1,8 @@
import { Notification } from "electron"; import { Notification } from "electron";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { t } from "i18next"; import { t } from "i18next";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const publishNewRepacksNotification = async ( const publishNewRepacksNotification = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -9,9 +10,12 @@ const publishNewRepacksNotification = async (
) => { ) => {
if (newRepacksCount < 1) return; if (newRepacksCount < 1) return;
const userPreferences = await userPreferencesRepository.findOne({ const userPreferences = await db.get<string, UserPreferences>(
where: { id: 1 }, levelKeys.userPreferences,
}); {
valueEncoding: "json",
}
);
if (userPreferences?.repackUpdatesNotificationsEnabled) { if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({ new Notification({

View file

@ -7,7 +7,7 @@ import { omit } from "lodash-es";
import axios from "axios"; import axios from "axios";
import { fileTypeFromFile } from "file-type"; import { fileTypeFromFile } from "file-type";
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch<UserProfile>("/profile", updateProfile); return HydraApi.patch<UserProfile>("/profile", updateProfile);
}; };

View file

@ -1,31 +1,19 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source"; import { GameShop } from "@types";
import { DownloadQueue, Game } from "@main/entity"; import { downloadsSublevel, levelKeys } from "@main/level";
const cancelGameDownload = async ( const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
await dataSource.transaction(async (transactionalEntityManager) => { const downloadKey = levelKeys.game(shop, objectId);
await DownloadManager.cancelDownload(gameId);
await transactionalEntityManager.getRepository(DownloadQueue).delete({ await DownloadManager.cancelDownload(downloadKey);
game: { id: gameId },
});
await transactionalEntityManager.getRepository(Game).update( await downloadsSublevel.del(downloadKey);
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
}; };
registerEvent("cancelGameDownload", cancelGameDownload); registerEvent("cancelGameDownload", cancelGameDownload);

View file

@ -1,24 +1,27 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source"; import { GameShop } from "@types";
import { DownloadQueue, Game } from "@main/entity"; import { downloadsSublevel, levelKeys } from "@main/level";
const pauseGameDownload = async ( const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
await dataSource.transaction(async (transactionalEntityManager) => { const gameKey = levelKeys.game(shop, objectId);
await DownloadManager.pauseDownload();
await transactionalEntityManager.getRepository(DownloadQueue).delete({ const download = await downloadsSublevel.get(gameKey);
game: { id: gameId },
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); registerEvent("pauseGameDownload", pauseGameDownload);

View file

@ -1,17 +1,24 @@
import { downloadsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { gameRepository } from "@main/repository"; import type { GameShop } from "@types";
const pauseGameSeed = async ( const pauseGameSeed = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
await gameRepository.update(gameId, { const downloadKey = levelKeys.game(shop, objectId);
status: "complete", const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(downloadKey, {
...download,
shouldSeed: false, shouldSeed: false,
}); });
await DownloadManager.pauseSeeding(gameId); await DownloadManager.pauseSeeding(downloadKey);
}; };
registerEvent("pauseGameSeed", pauseGameSeed); registerEvent("pauseGameSeed", pauseGameSeed);

View file

@ -1,46 +1,37 @@
import { Not } from "typeorm";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source"; import { downloadsSublevel, levelKeys } from "@main/level";
import { DownloadQueue, Game } from "@main/entity"; import { GameShop } from "@types";
const resumeGameDownload = async ( const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const game = await gameRepository.findOne({ const gameKey = levelKeys.game(shop, objectId);
where: {
id: gameId,
isDeleted: false,
},
});
if (!game) return; const download = await downloadsSublevel.get(gameKey);
if (game.status === "paused") { if (download?.status === "paused") {
await dataSource.transaction(async (transactionalEntityManager) => { await DownloadManager.pauseDownload();
await DownloadManager.pauseDownload();
await transactionalEntityManager for await (const [key, value] of downloadsSublevel.iterator()) {
.getRepository(Game) if (value.status === "active" && value.progress !== 1) {
.update({ status: "active", progress: Not(1) }, { status: "paused" }); await downloadsSublevel.put(key, {
...value,
status: "paused",
});
}
}
await DownloadManager.resumeDownload(game); await DownloadManager.resumeDownload(download);
await transactionalEntityManager await downloadsSublevel.put(gameKey, {
.getRepository(DownloadQueue) ...download,
.delete({ game: { id: gameId } }); status: "active",
timestamp: Date.now(),
await transactionalEntityManager queued: true,
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "active" });
}); });
} }
}; };

View file

@ -1,29 +1,23 @@
import { downloadsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { Downloader } from "@shared"; import type { GameShop } from "@types";
const resumeGameSeed = async ( const resumeGameSeed = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number shop: GameShop,
objectId: string
) => { ) => {
const game = await gameRepository.findOne({ const download = await downloadsSublevel.get(levelKeys.game(shop, objectId));
where: {
id: gameId,
isDeleted: false,
downloader: Downloader.Torrent,
progress: 1,
},
});
if (!game) return; if (!download) return;
await gameRepository.update(gameId, { await downloadsSublevel.put(levelKeys.game(shop, objectId), {
status: "seeding", ...download,
shouldSeed: true, shouldSeed: true,
}); });
await DownloadManager.resumeSeeding(game); await DownloadManager.resumeSeeding(download);
}; };
registerEvent("resumeGameSeed", resumeGameSeed); registerEvent("resumeGameSeed", resumeGameSeed);

View file

@ -1,13 +1,11 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types"; import type { Download, StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi } from "@main/services"; import { DownloadManager, HydraApi } from "@main/services";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { DownloadQueue, Game } from "@main/entity";
const startGameDownload = async ( const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -15,85 +13,85 @@ const startGameDownload = async (
) => { ) => {
const { objectId, title, shop, downloadPath, downloader, uri } = payload; const { objectId, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => { const gameKey = levelKeys.game(shop, objectId);
const gameRepository = transactionalEntityManager.getRepository(Game);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
const game = await gameRepository.findOne({ await DownloadManager.pauseDownload();
where: {
objectID: objectId,
shop,
},
});
await DownloadManager.pauseDownload(); for await (const [key, value] of downloadsSublevel.iterator()) {
if (value.status === "active" && value.progress !== 1) {
await gameRepository.update( await downloadsSublevel.put(key, {
{ status: "active", progress: Not(1) }, ...value,
{ status: "paused" } 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,
}); });
} }
}
const updatedGame = await gameRepository.findOne({ const game = await gamesSublevel.get(gameKey);
where: {
objectID: objectId, /* 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); const iconUrl = steamGame?.clientIcon
await DownloadManager.startDownload(updatedGame!); ? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await gamesSublevel.put(gameKey, {
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); title,
iconUrl,
objectId,
shop,
remoteId: null,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
isDeleted: false,
});
}
await Promise.all([ await DownloadManager.cancelDownload(gameKey);
createGame(updatedGame!).catch(() => {}),
HydraApi.post( const download: Download = {
"/games/download", shop,
{ objectId,
objectId: updatedGame!.objectID, status: "active",
shop: updatedGame!.shop, progress: 0,
}, bytesDownloaded: 0,
{ needsAuth: false } downloadPath,
).catch(() => {}), 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); registerEvent("startGameDownload", startGameDownload);

View file

@ -1,9 +1,21 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import type { UserPreferences } from "@types";
const getUserPreferences = async () => const getUserPreferences = async () =>
userPreferencesRepository.findOne({ db
where: { id: 1 }, .get<string, UserPreferences>(levelKeys.userPreferences, {
}); valueEncoding: "json",
})
.then((userPreferences) => {
if (userPreferences.realDebridApiToken) {
userPreferences.realDebridApiToken = Crypto.decrypt(
userPreferences.realDebridApiToken
);
}
return userPreferences;
});
registerEvent("getUserPreferences", getUserPreferences); registerEvent("getUserPreferences", getUserPreferences);

View file

@ -1,23 +1,37 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import i18next from "i18next"; import i18next from "i18next";
import { db, levelKeys } from "@main/level";
import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async ( const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
if (preferences.language) { if (preferences.language) {
await db.put<string, string>(levelKeys.language, preferences.language, {
valueEncoding: "utf-8",
});
i18next.changeLanguage(preferences.language); i18next.changeLanguage(preferences.language);
patchUserProfile({ language: preferences.language }).catch(() => {});
} }
return userPreferencesRepository.upsert( await db.put<string, UserPreferences>(
levelKeys.userPreferences,
{ {
id: 1, ...userPreferences,
...preferences, ...preferences,
}, },
["id"] {
valueEncoding: "json",
}
); );
}; };

View file

@ -1,7 +1,8 @@
import type { ComparedAchievements, GameShop } from "@types"; import type { ComparedAchievements, GameShop, UserPreferences } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
const getComparedUnlockedAchievements = async ( const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -9,9 +10,12 @@ const getComparedUnlockedAchievements = async (
shop: GameShop, shop: GameShop,
userId: string userId: string
) => { ) => {
const userPreferences = await userPreferencesRepository.findOne({ const userPreferences = await db.get<string, UserPreferences>(
where: { id: 1 }, levelKeys.userPreferences,
}); {
valueEncoding: "json",
}
);
const showHiddenAchievementsDescription = const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false; userPreferences?.showHiddenAchievementsDescription || false;

View file

@ -1,23 +1,23 @@
import type { GameShop, UnlockedAchievement, UserAchievement } from "@types"; import type { GameShop, UserAchievement, UserPreferences } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
export const getUnlockedAchievements = async ( export const getUnlockedAchievements = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
useCachedData: boolean useCachedData: boolean
): Promise<UserAchievement[]> => { ): Promise<UserAchievement[]> => {
const cachedAchievements = await gameAchievementRepository.findOne({ const cachedAchievements = await gameAchievementsSublevel.get(
where: { objectId, shop }, levelKeys.game(shop, objectId)
}); );
const userPreferences = await userPreferencesRepository.findOne({ const userPreferences = await db.get<string, UserPreferences>(
where: { id: 1 }, levelKeys.userPreferences,
}); {
valueEncoding: "json",
}
);
const showHiddenAchievementsDescription = const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false; userPreferences?.showHiddenAchievementsDescription || false;
@ -25,12 +25,10 @@ export const getUnlockedAchievements = async (
const achievementsData = await getGameAchievementData( const achievementsData = await getGameAchievementData(
objectId, objectId,
shop, shop,
useCachedData ? cachedAchievements : null useCachedData
); );
const unlockedAchievements = JSON.parse( const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
cachedAchievements?.unlockedAchievements || "[]"
) as UnlockedAchievement[];
return achievementsData return achievementsData
.map((achievementData) => { .map((achievementData) => {

View file

@ -1,16 +1,19 @@
import { userAuthRepository } from "@main/repository"; import { db } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; 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 ( export const getUserFriends = async (
userId: string, userId: string,
take: number, take: number,
skip: number skip: number
): Promise<UserFriends> => { ): Promise<UserFriends> => {
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); const user = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (loggedUser?.userId === userId) { if (user?.id === userId) {
return HydraApi.get(`/profile/friends`, { take, skip }); return HydraApi.get(`/profile/friends`, { take, skip });
} }

View file

@ -3,16 +3,13 @@ import updater from "electron-updater";
import i18n from "i18next"; import i18n from "i18next";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import fs from "node:fs";
import { electronApp, optimizer } from "@electron-toolkit/utils"; import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services"; import { logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import resources from "@locales"; 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 { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2"; import { Aria2 } from "./services/aria2";
import { db, levelKeys } from "./level";
import { loadState } from "./main";
const { autoUpdater } = updater; const { autoUpdater } = updater;
@ -50,21 +47,6 @@ if (process.defaultApp) {
app.setAsDefaultProtocolClient(PROTOCOL); 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 // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // 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()); return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
}); });
await runMigrations() await loadState();
.then(() => {
logger.log("Migrations executed successfully");
})
.catch((err) => {
logger.log("Migrations failed to run:", err);
});
await dataSource.initialize(); const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
await import("./main");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
}); });
if (userPreferences?.language) { if (language) i18n.changeLanguage(language);
i18n.changeLanguage(userPreferences.language);
}
if (!process.argv.includes("--hidden")) { if (!process.argv.includes("--hidden")) {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
} }
WindowManager.createSystemTray(userPreferences?.language || "en"); WindowManager.createSystemTray(language || "en");
}); });
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {

View file

@ -1,55 +1,6 @@
import knex, { Knex } from "knex"; import knex from "knex";
import { databasePath } from "./constants"; 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 { 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<HydraMigration> {
getMigrations(): Promise<HydraMigration[]> {
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<Knex.Migration> {
return Promise.resolve(migration);
}
}
export const knexClient = knex({ export const knexClient = knex({
debug: !app.isPackaged, debug: !app.isPackaged,
@ -58,7 +9,3 @@ export const knexClient = knex({
filename: databasePath, filename: databasePath,
}, },
}); });
export const migrationConfig: Knex.MigratorConfig = {
migrationSource: new MigrationSource(),
};

3
src/main/level/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { db } from "./level";
export * from "./sublevels";

6
src/main/level/level.ts Normal file
View file

@ -0,0 +1,6 @@
import { levelDatabasePath } from "@main/constants";
import { ClassicLevel } from "classic-level";
export const db = new ClassicLevel(levelDatabasePath, {
valueEncoding: "json",
});

View file

@ -0,0 +1,11 @@
import type { Download } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const downloadsSublevel = db.sublevel<string, Download>(
levelKeys.downloads,
{
valueEncoding: "json",
}
);

View file

@ -0,0 +1,11 @@
import type { GameAchievement } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gameAchievementsSublevel = db.sublevel<string, GameAchievement>(
levelKeys.gameAchievements,
{
valueEncoding: "json",
}
);

View file

@ -0,0 +1,11 @@
import type { ShopDetails } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopCacheSublevel = db.sublevel<string, ShopDetails>(
levelKeys.gameShopCache,
{
valueEncoding: "json",
}
);

View file

@ -0,0 +1,8 @@
import type { Game } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesSublevel = db.sublevel<string, Game>(levelKeys.games, {
valueEncoding: "json",
});

View file

@ -0,0 +1,6 @@
export * from "./downloads";
export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";

View file

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

View file

@ -1,29 +1,45 @@
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
import { import {
downloadQueueRepository, Crypto,
gameRepository, DownloadManager,
userPreferencesRepository, logger,
} from "./repository"; Ludusavi,
import { UserPreferences } from "./entity"; startMainLoop,
} from "./services";
import { RealDebridClient } from "./services/download/real-debrid"; import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api"; import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync"; import { uploadGamesBatch } from "./services/library-sync";
import { Aria2 } from "./services/aria2"; import { Aria2 } from "./services/aria2";
import { downloadsSublevel } from "./level/sublevels/downloads";
import { sortBy } from "lodash-es";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { IsNull, Not } from "typeorm"; import {
import { TorBoxClient } from "./services/download/torbox"; gameAchievementsSublevel,
gamesSublevel,
levelKeys,
db,
} from "./level";
import { Auth, User, type UserPreferences } from "@types";
import { knexClient } from "./knex-client";
const loadState = async (userPreferences: UserPreferences | null) => { export const loadState = async () => {
import("./events"); const userPreferences = await migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
return db.get<string, UserPreferences>(levelKeys.userPreferences, {
valueEncoding: "json",
});
});
await import("./events");
Aria2.spawn(); Aria2.spawn();
if (userPreferences?.realDebridApiToken) { if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken); RealDebridClient.authorize(
} Crypto.decrypt(userPreferences.realDebridApiToken)
);
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(userPreferences?.torBoxApiToken);
} }
Ludusavi.addManifestToLudusaviConfig(); Ludusavi.addManifestToLudusaviConfig();
@ -32,33 +48,157 @@ const loadState = async (userPreferences: UserPreferences | null) => {
uploadGamesBatch(); uploadGamesBatch();
}); });
const [nextQueueItem] = await downloadQueueRepository.find({ const downloads = await downloadsSublevel
order: { .values()
id: "DESC", .all()
}, .then((games) => {
relations: { return sortBy(
game: true, games.filter((game) => game.queued),
}, "timestamp",
}); "DESC"
);
});
const seedList = await gameRepository.find({ const [nextItemOnQueue] = downloads;
where: {
shouldSeed: true,
downloader: Downloader.Torrent,
progress: 1,
uri: Not(IsNull()),
},
});
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(); startMainLoop();
}; };
userPreferencesRepository const migrateFromSqlite = async () => {
.findOne({ const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone);
where: { id: 1 },
}) if (sqliteMigrationDone) {
.then((userPreferences) => { return;
loadState(userPreferences); }
});
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<string, User>(
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<string, Auth>(
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,
]);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import { gameRepository } from "@main/repository";
import { parseAchievementFile } from "./parse-achievement-file"; import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements"; import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs"; import fs, { readdirSync } from "node:fs";
import { import {
@ -9,21 +7,20 @@ import {
findAllAchievementFiles, findAllAchievementFiles,
getAlternativeObjectIds, getAlternativeObjectIds,
} from "./find-achivement-files"; } from "./find-achivement-files";
import type { AchievementFile, UnlockedAchievement } from "@types"; import type { AchievementFile, Game, UnlockedAchievement } from "@types";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { IsNull, Not } from "typeorm";
import { publishCombinedNewAchievementNotification } from "../notifications"; import { publishCombinedNewAchievementNotification } from "../notifications";
import { gamesSublevel } from "@main/level";
const fileStats: Map<string, number> = new Map(); const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map(); const fltFiles: Map<string, Set<string>> = new Map();
const watchAchievementsWindows = async () => { const watchAchievementsWindows = async () => {
const games = await gameRepository.find({ const games = await gamesSublevel
where: { .values()
isDeleted: false, .all()
}, .then((games) => games.filter((game) => !game.isDeleted));
});
if (games.length === 0) return; if (games.length === 0) return;
@ -32,7 +29,7 @@ const watchAchievementsWindows = async () => {
for (const game of games) { for (const game of games) {
const gameAchievementFiles: AchievementFile[] = []; const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectID)) { for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); gameAchievementFiles.push(...(achievementFiles.get(objectId) || []));
gameAchievementFiles.push( gameAchievementFiles.push(
@ -47,12 +44,12 @@ const watchAchievementsWindows = async () => {
}; };
const watchAchievementsWithWine = async () => { const watchAchievementsWithWine = async () => {
const games = await gameRepository.find({ const games = await gamesSublevel
where: { .values()
isDeleted: false, .all()
winePrefixPath: Not(IsNull()), .then((games) =>
}, games.filter((game) => !game.isDeleted && game.winePrefixPath)
}); );
for (const game of games) { for (const game of games) {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);
@ -144,7 +141,7 @@ const processAchievementFileDiff = async (
export class AchievementWatcherManager { export class AchievementWatcherManager {
private static hasFinishedMergingWithRemote = false; private static hasFinishedMergingWithRemote = false;
public static watchAchievements = () => { public static watchAchievements() {
if (!this.hasFinishedMergingWithRemote) return; if (!this.hasFinishedMergingWithRemote) return;
if (process.platform === "win32") { if (process.platform === "win32") {
@ -152,12 +149,12 @@ export class AchievementWatcherManager {
} }
return watchAchievementsWithWine(); return watchAchievementsWithWine();
}; }
private static preProcessGameAchievementFiles = ( private static preProcessGameAchievementFiles(
game: Game, game: Game,
gameAchievementFiles: AchievementFile[] gameAchievementFiles: AchievementFile[]
) => { ) {
const unlockedAchievements: UnlockedAchievement[] = []; const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) { for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = parseAchievementFile( const parsedAchievements = parseAchievementFile(
@ -185,14 +182,13 @@ export class AchievementWatcherManager {
} }
return mergeAchievements(game, unlockedAchievements, false); return mergeAchievements(game, unlockedAchievements, false);
}; }
private static preSearchAchievementsWindows = async () => { private static preSearchAchievementsWindows = async () => {
const games = await gameRepository.find({ const games = await gamesSublevel
where: { .values()
isDeleted: false, .all()
}, .then((games) => games.filter((game) => !game.isDeleted));
});
const gameAchievementFilesMap = findAllAchievementFiles(); const gameAchievementFilesMap = findAllAchievementFiles();
@ -200,7 +196,7 @@ export class AchievementWatcherManager {
games.map((game) => { games.map((game) => {
const gameAchievementFiles: AchievementFile[] = []; const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectID)) { for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push( gameAchievementFiles.push(
...(gameAchievementFilesMap.get(objectId) || []) ...(gameAchievementFilesMap.get(objectId) || [])
); );
@ -216,11 +212,10 @@ export class AchievementWatcherManager {
}; };
private static preSearchAchievementsWithWine = async () => { private static preSearchAchievementsWithWine = async () => {
const games = await gameRepository.find({ const games = await gamesSublevel
where: { .values()
isDeleted: false, .all()
}, .then((games) => games.filter((game) => !game.isDeleted));
});
return Promise.all( return Promise.all(
games.map((game) => { games.map((game) => {
@ -235,7 +230,7 @@ export class AchievementWatcherManager {
); );
}; };
public static preSearchAchievements = async () => { public static async preSearchAchievements() {
try { try {
const newAchievementsCount = const newAchievementsCount =
process.platform === "win32" process.platform === "win32"
@ -261,5 +256,5 @@ export class AchievementWatcherManager {
} }
this.hasFinishedMergingWithRemote = true; this.hasFinishedMergingWithRemote = true;
}; }
} }

View file

@ -1,9 +1,8 @@
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { app } from "electron"; import { app } from "electron";
import type { AchievementFile } from "@types"; import type { Game, AchievementFile } from "@types";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { Game } from "@main/entity";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
const getAppDataPath = () => { const getAppDataPath = () => {
@ -254,7 +253,7 @@ export const findAchievementFiles = (game: Game) => {
for (const cracker of crackers) { for (const cracker of crackers) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { 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( const filePath = path.join(
game.winePrefixPath ?? "", game.winePrefixPath ?? "",
folderPath, folderPath,

View file

@ -1,40 +1,37 @@
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import type { AchievementData, GameShop } from "@types"; import type { GameShop, SteamAchievement } from "@types";
import { UserNotLoggedInError } from "@shared"; import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger"; import { logger } from "../logger";
import { GameAchievement } from "@main/entity"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
export const getGameAchievementData = async ( export const getGameAchievementData = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
cachedAchievements: GameAchievement | null useCachedData: boolean
) => { ) => {
if (cachedAchievements && cachedAchievements.achievements) { const cachedAchievements = await gameAchievementsSublevel.get(
return JSON.parse(cachedAchievements.achievements) as AchievementData[]; levelKeys.game(shop, objectId)
} );
const userPreferences = await userPreferencesRepository.findOne({ if (cachedAchievements && useCachedData)
where: { id: 1 }, return cachedAchievements.achievements;
});
return HydraApi.get<AchievementData[]>("/games/achievements", { const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
})
.then((language) => language || "en");
return HydraApi.get<SteamAchievement[]>("/games/achievements", {
shop, shop,
objectId, objectId,
language: userPreferences?.language || "en", language,
}) })
.then((achievements) => { .then(async (achievements) => {
gameAchievementRepository.upsert( await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), {
{ unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
objectId, achievements,
shop, });
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
return achievements; return achievements;
}) })
@ -42,15 +39,9 @@ export const getGameAchievementData = async (
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
throw err; throw err;
} }
logger.error("Failed to get game achievements", err);
return gameAchievementRepository logger.error("Failed to get game achievements for", objectId, err);
.findOne({
where: { objectId, shop }, return [];
})
.then((gameAchievements) => {
return JSON.parse(
gameAchievements?.achievements || "[]"
) as AchievementData[];
});
}); });
}; };

View file

@ -1,42 +1,45 @@
import { import type {
gameAchievementRepository, Game,
userPreferencesRepository, GameShop,
} from "@main/repository"; UnlockedAchievement,
import type { AchievementData, GameShop, UnlockedAchievement } from "@types"; UserPreferences,
} from "@types";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements";
import { Game } from "@main/entity";
import { publishNewAchievementNotification } from "../notifications"; import { publishNewAchievementNotification } from "../notifications";
import { SubscriptionRequiredError } from "@shared"; import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
const saveAchievementsOnLocal = async ( const saveAchievementsOnLocal = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
achievements: UnlockedAchievement[], unlockedAchievements: UnlockedAchievement[],
sendUpdateEvent: boolean sendUpdateEvent: boolean
) => { ) => {
return gameAchievementRepository const levelKey = levelKeys.game(shop, objectId);
.upsert(
{
objectId,
shop,
unlockedAchievements: JSON.stringify(achievements),
},
["objectId", "shop"]
)
.then(() => {
if (!sendUpdateEvent) return;
return getUnlockedAchievements(objectId, shop, true) return gameAchievementsSublevel
.then((achievements) => { .get(levelKey)
WindowManager.mainWindow?.webContents.send( .then(async (gameAchievement) => {
`on-update-achievements-${objectId}-${shop}`, if (gameAchievement) {
achievements await gameAchievementsSublevel.put(levelKey, {
); ...gameAchievement,
}) unlockedAchievements: unlockedAchievements,
.catch(() => {}); });
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 publishNotification: boolean
) => { ) => {
const [localGameAchievement, userPreferences] = await Promise.all([ const [localGameAchievement, userPreferences] = await Promise.all([
gameAchievementRepository.findOne({ gameAchievementsSublevel.get(levelKeys.game(game.shop, game.objectId)),
where: { db.get<string, UserPreferences>(levelKeys.userPreferences, {
objectId: game.objectID, valueEncoding: "json",
shop: game.shop,
},
}), }),
userPreferencesRepository.findOne({ where: { id: 1 } }),
]); ]);
const achievementsData = JSON.parse( const achievementsData = localGameAchievement?.achievements ?? [];
localGameAchievement?.achievements || "[]" const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
) as AchievementData[];
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
).filter((achievement) => achievement.name) as UnlockedAchievement[];
const newAchievementsMap = new Map( const newAchievementsMap = new Map(
achievements.reverse().map((achievement) => { achievements.toReversed().map((achievement) => {
return [achievement.name.toUpperCase(), achievement]; return [achievement.name.toUpperCase(), achievement];
}) })
); );
@ -92,7 +87,7 @@ export const mergeAchievements = async (
userPreferences?.achievementNotificationsEnabled userPreferences?.achievementNotificationsEnabled
) { ) {
const achievementsInfo = newAchievements const achievementsInfo = newAchievements
.sort((a, b) => { .toSorted((a, b) => {
return a.unlockTime - b.unlockTime; return a.unlockTime - b.unlockTime;
}) })
.map((achievement) => { .map((achievement) => {
@ -141,13 +136,13 @@ export const mergeAchievements = async (
if (err! instanceof SubscriptionRequiredError) { if (err! instanceof SubscriptionRequiredError) {
achievementsLogger.log( achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription", "Achievements not synchronized on API due to lack of subscription",
game.objectID, game.objectId,
game.title game.title
); );
} }
return saveAchievementsOnLocal( return saveAchievementsOnLocal(
game.objectID, game.objectId,
game.shop, game.shop,
mergedLocalAchievements, mergedLocalAchievements,
publishNotification publishNotification
@ -155,7 +150,7 @@ export const mergeAchievements = async (
}); });
} else { } else {
await saveAchievementsOnLocal( await saveAchievementsOnLocal(
game.objectID, game.objectId,
game.shop, game.shop,
mergedLocalAchievements, mergedLocalAchievements,
publishNotification publishNotification

View file

@ -4,8 +4,7 @@ import {
} from "./find-achivement-files"; } from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file"; import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements"; import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types"; import type { Game, UnlockedAchievement } from "@types";
import { Game } from "@main/entity";
export const updateLocalUnlockedAchivements = async (game: Game) => { export const updateLocalUnlockedAchivements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);

View file

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

View file

@ -1,13 +1,7 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import type { DownloadProgress } from "@types"; import type { Download, DownloadProgress, UserPreferences } from "@types";
import { GofileApi, QiwiApi, DatanodesApi } from "../hosters"; import { GofileApi, QiwiApi, DatanodesApi } from "../hosters";
import { PythonRPC } from "../python-rpc"; import { PythonRPC } from "../python-rpc";
import { import {
@ -16,40 +10,43 @@ import {
PauseDownloadPayload, PauseDownloadPayload,
} from "./types"; } from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "path"; import path from "path";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox"; import { TorBoxClient } from "./torbox";
import axios from "axios"; import axios from "axios";
export class DownloadManager { 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( PythonRPC.spawn(
game?.status === "active" download?.status === "active"
? await this.getDownloadPayload(game).catch(() => undefined) ? await this.getDownloadPayload(download).catch(() => undefined)
: undefined, : undefined,
downloadsToSeed?.map((download) => ({
initialSeeding?.map((game) => ({ game_id: `${download.shop}-${download.objectId}`,
game_id: game.id, url: download.uri,
url: game.uri!, save_path: download.downloadPath,
save_path: game.downloadPath!,
})) }))
); );
this.downloadingGameId = game?.id ?? null; if (download) {
this.downloadingGameId = `${download.shop}-${download.objectId}`;
}
} }
private static async getDownloadStatus() { private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>( const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status" "/status"
); );
if (response.data === null || !this.downloadingGameId) return null; if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
const gameId = this.downloadingGameId;
try { try {
const { const {
@ -65,24 +62,21 @@ export class DownloadManager {
const isDownloadingMetadata = const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata; status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
if (!isDownloadingMetadata && !isCheckingFiles) { if (!isDownloadingMetadata && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = { if (!download) return null;
await downloadsSublevel.put(downloadId, {
...download,
bytesDownloaded, bytesDownloaded,
fileSize, fileSize,
progress, progress,
folderName,
status: "active", status: "active",
}; });
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
} }
return { return {
@ -93,7 +87,8 @@ export class DownloadManager {
isDownloadingMetadata, isDownloadingMetadata,
isCheckingFiles, isCheckingFiles,
progress, progress,
gameId, gameId: downloadId,
download,
} as DownloadProgress; } as DownloadProgress;
} catch (err) { } catch (err) {
return null; return null;
@ -105,14 +100,22 @@ export class DownloadManager {
if (status) { if (status) {
const { gameId, progress } = 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<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send( WindowManager.mainWindow.webContents.send(
"on-download-progress", "on-download-progress",
@ -124,39 +127,48 @@ export class DownloadManager {
) )
); );
} }
if (progress === 1 && game) {
if (progress === 1 && download) {
publishDownloadCompleteNotification(game); publishDownloadCompleteNotification(game);
if ( if (
userPreferences?.seedAfterDownloadComplete && userPreferences?.seedAfterDownloadComplete &&
game.downloader === Downloader.Torrent download.downloader === Downloader.Torrent
) { ) {
gameRepository.update( downloadsSublevel.put(gameId, {
{ id: gameId }, ...download,
{ status: "seeding", shouldSeed: true } status: "seeding",
); shouldSeed: true,
queued: false,
});
} else { } else {
gameRepository.update( downloadsSublevel.put(gameId, {
{ id: gameId }, ...download,
{ status: "complete", shouldSeed: false } status: "complete",
); shouldSeed: false,
queued: false,
});
this.cancelDownload(gameId); this.cancelDownload(gameId);
} }
await downloadQueueRepository.delete({ game }); const downloads = await downloadsSublevel
const [nextQueueItem] = await downloadQueueRepository.find({ .values()
order: { .all()
id: "DESC", .then((games) => {
}, return sortBy(
relations: { games.filter((game) => game.status === "paused" && game.queued),
game: true, "timestamp",
}, "DESC"
}); );
if (nextQueueItem) { });
this.resumeDownload(nextQueueItem.game);
const [nextItemOnQueue] = downloads;
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else { } else {
this.downloadingGameId = -1; this.downloadingGameId = null;
} }
} }
} }
@ -172,20 +184,19 @@ export class DownloadManager {
logger.log(seedStatus); logger.log(seedStatus);
seedStatus.forEach(async (status) => { seedStatus.forEach(async (status) => {
const game = await gameRepository.findOne({ const download = await downloadsSublevel.get(status.gameId);
where: { id: status.gameId },
});
if (!game) return; if (!download) return;
const totalSize = await getDirSize( const totalSize = await getDirSize(
path.join(game.downloadPath!, status.folderName) path.join(download.downloadPath, status.folderName)
); );
if (totalSize < status.fileSize) { 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", status: "paused",
shouldSeed: false, shouldSeed: false,
progress: totalSize / status.fileSize, progress: totalSize / status.fileSize,
@ -198,65 +209,64 @@ export class DownloadManager {
WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus); WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus);
} }
static async pauseDownload() { static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc await PythonRPC.rpc
.post("/action", { .post("/action", {
action: "pause", action: "pause",
game_id: this.downloadingGameId, game_id: downloadKey,
} as PauseDownloadPayload) } as PauseDownloadPayload)
.catch(() => {}); .catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null; this.downloadingGameId = null;
} }
static async resumeDownload(game: Game) { static async resumeDownload(download: Download) {
return this.startDownload(game); return this.startDownload(download);
} }
static async cancelDownload(gameId = this.downloadingGameId!) { static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "cancel", action: "cancel",
game_id: gameId, game_id: downloadKey,
}); });
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
if (downloadKey === this.downloadingGameId) {
if (gameId === this.downloadingGameId) {
this.downloadingGameId = null; this.downloadingGameId = null;
} }
} }
static async resumeSeeding(game: Game) { static async resumeSeeding(download: Download) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "resume_seeding", action: "resume_seeding",
game_id: game.id, game_id: levelKeys.game(download.shop, download.objectId),
url: game.uri, url: download.uri,
save_path: game.downloadPath, save_path: download.downloadPath,
}); });
} }
static async pauseSeeding(gameId: number) { static async pauseSeeding(downloadKey: string) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "pause_seeding", action: "pause_seeding",
game_id: gameId, game_id: downloadKey,
}); });
} }
private static async getDownloadPayload(game: Game) { private static async getDownloadPayload(download: Download) {
switch (game.downloader) { const downloadId = levelKeys.game(download.shop, download.objectId);
case Downloader.Gofile: {
const id = game.uri!.split("/").pop();
switch (download.downloader) {
case Downloader.Gofile: {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize(); const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
return { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: downloadLink, url: downloadLink,
save_path: game.downloadPath!, save_path: download.downloadPath,
header: `Cookie: accountToken=${token}`, header: `Cookie: accountToken=${token}`,
}; };
} }
@ -269,47 +279,50 @@ export class DownloadManager {
return { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: `https://pixeldrain.com/api/file/${id}?download`, url: `https://pixeldrain.com/api/file/${id}?download`,
save_path: game.downloadPath!, save_path: game.downloadPath!,
out: name, out: name,
}; };
} }
case Downloader.Qiwi: { case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!); const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: downloadUrl, url: downloadUrl,
save_path: game.downloadPath!, save_path: download.downloadPath,
}; };
} }
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(game.uri!); const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: downloadUrl, url: downloadUrl,
save_path: game.downloadPath!, save_path: download.downloadPath,
}; };
} }
case Downloader.Torrent: case Downloader.Torrent:
return { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: game.uri!, url: download.uri,
save_path: game.downloadPath!, save_path: download.downloadPath,
}; };
case Downloader.RealDebrid: { 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 { return {
action: "start", action: "start",
game_id: game.id, game_id: downloadId,
url: downloadUrl!, url: downloadUrl,
save_path: game.downloadPath!, save_path: download.downloadPath,
}; };
} }
case Downloader.TorBox: { case Downloader.TorBox: {
@ -328,11 +341,9 @@ export class DownloadManager {
} }
} }
static async startDownload(game: Game) { static async startDownload(download: Download) {
const payload = await this.getDownloadPayload(game); const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload); await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
this.downloadingGameId = game.id;
} }
} }

View file

@ -1,9 +1,9 @@
export interface PauseDownloadPayload { export interface PauseDownloadPayload {
game_id: number; game_id: string;
} }
export interface CancelDownloadPayload { export interface CancelDownloadPayload {
game_id: number; game_id: string;
} }
export enum LibtorrentStatus { export enum LibtorrentStatus {
@ -24,7 +24,7 @@ export interface LibtorrentPayload {
fileSize: number; fileSize: number;
folderName: string; folderName: string;
status: LibtorrentStatus; status: LibtorrentStatus;
gameId: number; gameId: string;
} }
export interface ProcessPayload { export interface ProcessPayload {

View file

@ -1,18 +1,18 @@
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import axios, { AxiosError, AxiosInstance } from "axios"; import axios, { AxiosError, AxiosInstance } from "axios";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import url from "url"; import url from "url";
import { uploadGamesBatch } from "./library-sync"; import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; 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 { UserNotLoggedInError, SubscriptionRequiredError } from "@shared";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import { appVersion } from "@main/constants"; import { appVersion } from "@main/constants";
import { getUserData } from "./user/get-user-data"; import { getUserData } from "./user/get-user-data";
import { isFuture, isToday } from "date-fns"; 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 { interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@ -32,7 +32,8 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true; 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 = { private static userAuth: HydraApiUserAuth = {
authToken: "", authToken: "",
@ -77,14 +78,14 @@ export class HydraApi {
tokenExpirationTimestamp tokenExpirationTimestamp
); );
await userAuthRepository.upsert( db.put<string, Auth>(
levelKeys.auth,
{ {
id: 1, accessToken: Crypto.encrypt(accessToken),
accessToken, refreshToken: Crypto.encrypt(refreshToken),
tokenExpirationTimestamp, tokenExpirationTimestamp,
refreshToken,
}, },
["id"] { valueEncoding: "json" }
); );
await getUserData().then((userDetails) => { await getUserData().then((userDetails) => {
@ -153,7 +154,8 @@ export class HydraApi {
(error) => { (error) => {
logger.error(" ---- RESPONSE ERROR -----"); logger.error(" ---- RESPONSE ERROR -----");
const { config } = error; const { config } = error;
const data = JSON.parse(config.data);
const data = JSON.parse(config.data ?? null);
logger.error( logger.error(
config.method, config.method,
@ -174,29 +176,43 @@ export class HydraApi {
error.response.status, error.response.status,
error.response.data error.response.data
); );
} else if (error.request) {
const errorData = error.toJSON(); return Promise.reject(error as Error);
logger.error("Request error:", errorData.message);
} else {
logger.error("Error", error.message);
} }
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({ const result = await db.getMany<string>([levelKeys.auth, levelKeys.user], {
where: { id: 1 }, valueEncoding: "json",
relations: { subscription: true },
}); });
const userAuth = result.at(0) as Auth | undefined;
const user = result.at(1) as User | undefined;
this.userAuth = { this.userAuth = {
authToken: userAuth?.accessToken ?? "", authToken: userAuth?.accessToken
refreshToken: userAuth?.refreshToken ?? "", ? Crypto.decrypt(userAuth.accessToken)
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: userAuth?.subscription subscription: user?.subscription
? { expiresAt: userAuth.subscription?.expiresAt } ? { expiresAt: user.subscription?.expiresAt }
: null, : null,
}; };
@ -215,38 +231,47 @@ export class HydraApi {
} }
} }
private static async revalidateAccessTokenIfExpired() { public static async refreshToken() {
const now = new Date(); const response = await this.instance.post(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
});
if (this.userAuth.expirationTimestamp < now.getTime()) { const { accessToken, expiresIn } = response.data;
try {
const response = await this.instance.post(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
});
const { accessToken, expiresIn } = response.data; const tokenExpirationTimestamp =
Date.now() +
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
const tokenExpirationTimestamp = this.userAuth.authToken = accessToken;
now.getTime() + this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
this.userAuth.authToken = accessToken; logger.log(
this.userAuth.expirationTimestamp = tokenExpirationTimestamp; "Token refreshed. New expiration:",
this.userAuth.expirationTimestamp
);
logger.log( await db
"Token refreshed. New expiration:", .get<string, Auth>(levelKeys.auth, { valueEncoding: "json" })
this.userAuth.expirationTimestamp .then((auth) => {
); return db.put<string, Auth>(
levelKeys.auth,
userAuthRepository.upsert(
{ {
id: 1, ...auth,
accessToken, accessToken: Crypto.encrypt(accessToken),
tokenExpirationTimestamp, tokenExpirationTimestamp,
}, },
["id"] { valueEncoding: "json" }
); );
});
return { accessToken, expiresIn };
}
private static async revalidateAccessTokenIfExpired() {
if (this.userAuth.expirationTimestamp < Date.now()) {
try {
await this.refreshToken();
} catch (err) { } catch (err) {
this.handleUnauthorizedError(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) { if (err instanceof AxiosError && err.response?.status === 401) {
logger.error( logger.error(
"401 - Current credentials:", "401 - Current credentials:",
@ -276,8 +301,16 @@ export class HydraApi {
subscription: null, subscription: null,
}; };
userAuthRepository.delete({ id: 1 }); db.batch([
userSubscriptionRepository.delete({ id: 1 }); {
type: "del",
key: levelKeys.auth,
},
{
type: "del",
key: levelKeys.user,
},
]);
this.sendSignOutEvent(); this.sendSignOutEvent();
} }

View file

@ -1,3 +1,4 @@
export * from "./crypto";
export * from "./logger"; export * from "./logger";
export * from "./steam"; export * from "./steam";
export * from "./steam-250"; export * from "./steam-250";

Some files were not shown because too many files have changed in this diff Show more