Merge branch 'feat/migration-to-leveldb' into feature/custom-themes

This commit is contained in:
Hachi-R
2025-01-23 15:45:23 -03:00
parent 44aed56461
commit 6bf049d136
131 changed files with 1918 additions and 2597 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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 { WindowManager } from "../window-manager";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import type { DownloadProgress } from "@types";
import type { Download, DownloadProgress, UserPreferences } from "@types";
import { GofileApi, QiwiApi, DatanodesApi } from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -16,37 +10,41 @@ import {
PauseDownloadPayload,
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
export class DownloadManager {
private static downloadingGameId: number | null = null;
private static downloadingGameId: string | null = null;
public static async startRPC(game?: Game, initialSeeding?: Game[]) {
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
) {
PythonRPC.spawn(
game?.status === "active"
? await this.getDownloadPayload(game).catch(() => undefined)
download?.status === "active"
? await this.getDownloadPayload(download).catch(() => undefined)
: undefined,
initialSeeding?.map((game) => ({
game_id: game.id,
url: game.uri!,
save_path: game.downloadPath!,
downloadsToSeed?.map((download) => ({
game_id: `${download.shop}-${download.objectId}`,
url: download.uri,
save_path: download.downloadPath,
}))
);
this.downloadingGameId = game?.id ?? null;
if (download) {
this.downloadingGameId = `${download.shop}-${download.objectId}`;
}
}
private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
if (response.data === null || !this.downloadingGameId) return null;
const gameId = this.downloadingGameId;
const downloadId = this.downloadingGameId;
try {
const {
@@ -62,24 +60,21 @@ export class DownloadManager {
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
if (!isDownloadingMetadata && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = {
if (!download) return null;
await downloadsSublevel.put(downloadId, {
...download,
bytesDownloaded,
fileSize,
progress,
folderName,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
});
}
return {
@@ -90,7 +85,8 @@ export class DownloadManager {
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId,
gameId: downloadId,
download,
} as DownloadProgress;
} catch (err) {
return null;
@@ -102,14 +98,22 @@ export class DownloadManager {
if (status) {
const { gameId, progress } = status;
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
const userPreferences = await userPreferencesRepository.findOneBy({
id: 1,
});
if (WindowManager.mainWindow && game) {
const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return;
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
@@ -121,39 +125,48 @@ export class DownloadManager {
)
);
}
if (progress === 1 && game) {
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
if (
userPreferences?.seedAfterDownloadComplete &&
game.downloader === Downloader.Torrent
download.downloader === Downloader.Torrent
) {
gameRepository.update(
{ id: gameId },
{ status: "seeding", shouldSeed: true }
);
downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
});
} else {
gameRepository.update(
{ id: gameId },
{ status: "complete", shouldSeed: false }
);
downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
});
this.cancelDownload(gameId);
}
await downloadQueueRepository.delete({ game });
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => {
return sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
);
});
const [nextItemOnQueue] = downloads;
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = -1;
this.downloadingGameId = null;
}
}
}
@@ -169,20 +182,19 @@ export class DownloadManager {
logger.log(seedStatus);
seedStatus.forEach(async (status) => {
const game = await gameRepository.findOne({
where: { id: status.gameId },
});
const download = await downloadsSublevel.get(status.gameId);
if (!game) return;
if (!download) return;
const totalSize = await getDirSize(
path.join(game.downloadPath!, status.folderName)
path.join(download.downloadPath, status.folderName)
);
if (totalSize < status.fileSize) {
await this.cancelDownload(game.id);
await this.cancelDownload(status.gameId);
await gameRepository.update(game.id, {
await downloadsSublevel.put(status.gameId, {
...download,
status: "paused",
shouldSeed: false,
progress: totalSize / status.fileSize,
@@ -195,123 +207,122 @@ export class DownloadManager {
WindowManager.mainWindow?.webContents.send("on-seeding-status", seedStatus);
}
static async pauseDownload() {
static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
}
static async resumeDownload(game: Game) {
return this.startDownload(game);
static async resumeDownload(download: Download) {
return this.startDownload(download);
}
static async cancelDownload(gameId = this.downloadingGameId!) {
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: gameId,
game_id: downloadKey,
});
WindowManager.mainWindow?.setProgressBar(-1);
if (gameId === this.downloadingGameId) {
if (downloadKey === this.downloadingGameId) {
this.downloadingGameId = null;
}
}
static async resumeSeeding(game: Game) {
static async resumeSeeding(download: Download) {
await PythonRPC.rpc.post("/action", {
action: "resume_seeding",
game_id: game.id,
url: game.uri,
save_path: game.downloadPath,
game_id: levelKeys.game(download.shop, download.objectId),
url: download.uri,
save_path: download.downloadPath,
});
}
static async pauseSeeding(gameId: number) {
static async pauseSeeding(downloadKey: string) {
await PythonRPC.rpc.post("/action", {
action: "pause_seeding",
game_id: gameId,
game_id: downloadKey,
});
}
private static async getDownloadPayload(game: Game) {
switch (game.downloader) {
case Downloader.Gofile: {
const id = game.uri!.split("/").pop();
private static async getDownloadPayload(download: Download) {
const downloadId = levelKeys.game(download.shop, download.objectId);
switch (download.downloader) {
case Downloader.Gofile: {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
return {
action: "start",
game_id: game.id,
game_id: downloadId,
url: downloadLink,
save_path: game.downloadPath!,
save_path: download.downloadPath,
header: `Cookie: accountToken=${token}`,
};
}
case Downloader.PixelDrain: {
const id = game.uri!.split("/").pop();
const id = download.uri.split("/").pop();
return {
action: "start",
game_id: game.id,
game_id: downloadId,
url: `https://pixeldrain.com/api/file/${id}?download`,
save_path: game.downloadPath!,
save_path: download.downloadPath,
};
}
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: game.id,
game_id: downloadId,
url: downloadUrl,
save_path: game.downloadPath!,
save_path: download.downloadPath,
};
}
case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(game.uri!);
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: game.id,
game_id: downloadId,
url: downloadUrl,
save_path: game.downloadPath!,
save_path: download.downloadPath,
};
}
case Downloader.Torrent:
return {
action: "start",
game_id: game.id,
url: game.uri!,
save_path: game.downloadPath!,
game_id: downloadId,
url: download.uri,
save_path: download.downloadPath,
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!);
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl)
throw new Error(
"This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available."
);
return {
action: "start",
game_id: game.id,
url: downloadUrl!,
save_path: game.downloadPath!,
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
}
}
static async startDownload(game: Game) {
const payload = await this.getDownloadPayload(game);
static async startDownload(download: Download) {
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = game.id;
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
}
}

View File

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

View File

@@ -1,7 +1,3 @@
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import axios, { AxiosError, AxiosInstance } from "axios";
import { WindowManager } from "./window-manager";
import url from "url";
@@ -13,6 +9,10 @@ import { omit } from "lodash-es";
import { appVersion } from "@main/constants";
import { getUserData } from "./user/get-user-data";
import { isFuture, isToday } from "date-fns";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -77,14 +77,14 @@ export class HydraApi {
tokenExpirationTimestamp
);
await userAuthRepository.upsert(
db.put<string, Auth>(
levelKeys.auth,
{
id: 1,
accessToken,
accessToken: Crypto.encrypt(accessToken),
refreshToken: Crypto.encrypt(refreshToken),
tokenExpirationTimestamp,
refreshToken,
},
["id"]
{ valueEncoding: "json" }
);
await getUserData().then((userDetails) => {
@@ -186,17 +186,23 @@ export class HydraApi {
);
}
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },
relations: { subscription: true },
const result = await db.getMany<string>([levelKeys.auth, levelKeys.user], {
valueEncoding: "json",
});
const userAuth = result.at(0) as Auth | undefined;
const user = result.at(1) as User | undefined;
this.userAuth = {
authToken: userAuth?.accessToken ?? "",
refreshToken: userAuth?.refreshToken ?? "",
authToken: userAuth?.accessToken
? Crypto.decrypt(userAuth.accessToken)
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: userAuth?.subscription
? { expiresAt: userAuth.subscription?.expiresAt }
subscription: user?.subscription
? { expiresAt: user.subscription?.expiresAt }
: null,
};
@@ -216,11 +222,11 @@ export class HydraApi {
}
public static async refreshToken() {
const { accessToken, expiresIn } = await this.instance
.post<{ accessToken: string; expiresIn: number }>(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
})
.then((response) => response.data);
const response = await this.instance.post(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
});
const { accessToken, expiresIn } = response.data;
const tokenExpirationTimestamp =
Date.now() +
@@ -235,14 +241,19 @@ export class HydraApi {
this.userAuth.expirationTimestamp
);
userAuthRepository.upsert(
{
id: 1,
accessToken,
tokenExpirationTimestamp,
},
["id"]
);
await db
.get<string, Auth>(levelKeys.auth, { valueEncoding: "json" })
.then((auth) => {
return db.put<string, Auth>(
levelKeys.auth,
{
...auth,
accessToken: Crypto.encrypt(accessToken),
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }
);
});
return { accessToken, expiresIn };
}
@@ -280,8 +291,16 @@ export class HydraApi {
subscription: null,
};
userAuthRepository.delete({ id: 1 });
userSubscriptionRepository.delete({ id: 1 });
db.batch([
{
type: "del",
key: levelKeys.auth,
},
{
type: "del",
key: levelKeys.user,
},
]);
this.sendSignOutEvent();
}

View File

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

View File

@@ -1,5 +1,16 @@
import { gameRepository } from "@main/repository";
import { gamesSublevel, levelKeys } from "@main/level";
export const clearGamesRemoteIds = () => {
return gameRepository.update({}, { remoteId: null });
export const clearGamesRemoteIds = async () => {
const games = await gamesSublevel.values().all();
await gamesSublevel.batch(
games.map((game) => ({
type: "put",
key: levelKeys.game(game.shop, game.objectId),
value: {
...game,
remoteId: null,
},
}))
);
};

View File

@@ -1,19 +1,21 @@
import { Game } from "@main/entity";
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository";
import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
return HydraApi.post(`/profile/games`, {
objectId: game.objectID,
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
}).then((response) => {
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
gameRepository.update(
{ objectID: game.objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
remoteId,
playTimeInMilliseconds,
lastTimePlayed,
});
});
};

View File

@@ -1,17 +1,15 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { steamUrlBuilder } from "@shared";
import { gamesSublevel, levelKeys } from "@main/level";
export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games")
.then(async (response) => {
for (const game of response) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,
},
});
const localGame = await gamesSublevel.get(
levelKeys.game(game.shop, game.objectId)
);
if (localGame) {
const updatedLastTimePlayed =
@@ -26,17 +24,12 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gameRepository.update(
{
objectID: game.objectId,
shop: "steam",
},
{
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...localGame,
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
});
} else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
@@ -47,14 +40,15 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({
objectID: game.objectId,
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false,
});
}
}

View File

@@ -1,4 +1,4 @@
import { Game } from "@main/entity";
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async (

View File

@@ -1,15 +1,19 @@
import { gameRepository } from "@main/repository";
import { chunk } from "lodash-es";
import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api";
import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager";
import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager";
import { gamesSublevel } from "@main/level";
export const uploadGamesBatch = async () => {
const games = await gameRepository.find({
where: { remoteId: IsNull(), isDeleted: false },
});
const games = await gamesSublevel
.values()
.all()
.then((results) => {
return results.filter(
(game) => !game.isDeleted && game.remoteId === null
);
});
const gamesChunks = chunk(games, 200);
@@ -18,7 +22,7 @@ export const uploadGamesBatch = async () => {
"/profile/games/batch",
chunk.map((game) => {
return {
objectId: game.objectID,
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,

View File

@@ -1,8 +1,6 @@
import { Notification, app } from "electron";
import { t } from "i18next";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { userPreferencesRepository } from "@main/repository";
import fs from "node:fs";
import axios from "axios";
import path from "node:path";
@@ -11,6 +9,8 @@ import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import type { Game, UserPreferences } from "@types";
import { db, levelKeys } from "@main/level";
async function downloadImage(url: string | null) {
if (!url) return undefined;
@@ -38,9 +38,12 @@ async function downloadImage(url: string | null) {
}
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({

View File

@@ -1,12 +1,11 @@
import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import type { GameRunning } from "@types";
import type { Game, GameRunning } from "@types";
import { PythonRPC } from "./python-rpc";
import { Game } from "@main/entity";
import axios from "axios";
import { exec } from "child_process";
import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level";
const commands = {
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
@@ -14,7 +13,7 @@ const commands = {
};
export const gamesPlaytime = new Map<
number,
string,
{ lastTick: number; firstTick: number; lastSyncTick: number }
>();
@@ -82,23 +81,28 @@ const findGamePathByProcess = (
const pathSet = processMap.get(executable.exe);
if (pathSet) {
pathSet.forEach((path) => {
pathSet.forEach(async (path) => {
if (path.toLowerCase().endsWith(executable.name)) {
gameRepository.update(
{ objectID: gameId, shop: "steam" },
{ executablePath: path }
);
const gameKey = levelKeys.game("steam", gameId);
const game = await gamesSublevel.get(gameKey);
if (game) {
gamesSublevel.put(gameKey, {
...game,
executablePath: path,
});
}
if (isLinuxPlatform) {
exec(commands.findWineDir, (err, out) => {
if (err) return;
gameRepository.update(
{ objectID: gameId, shop: "steam" },
{
if (game) {
gamesSublevel.put(gameKey, {
...game,
winePrefixPath: out.trim().replace("/drive_c/windows", ""),
}
);
});
}
});
}
}
@@ -159,11 +163,12 @@ const getSystemProcessMap = async () => {
};
export const watchProcesses = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
const games = await gamesSublevel
.values()
.all()
.then((results) => {
return results.filter((game) => game.isDeleted === false);
});
if (!games.length) return;
@@ -172,8 +177,8 @@ export const watchProcesses = async () => {
for (const game of games) {
const executablePath = game.executablePath;
if (!executablePath) {
if (gameExecutables[game.objectID]) {
findGamePathByProcess(processMap, game.objectID);
if (gameExecutables[game.objectId]) {
findGamePathByProcess(processMap, game.objectId);
}
continue;
}
@@ -185,12 +190,12 @@ export const watchProcesses = async () => {
const hasProcess = processMap.get(executable)?.has(executablePath);
if (hasProcess) {
if (gamesPlaytime.has(game.id)) {
if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) {
onTickGame(game);
} else {
onOpenGame(game);
}
} else if (gamesPlaytime.has(game.id)) {
} else if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) {
onCloseGame(game);
}
}
@@ -202,20 +207,17 @@ export const watchProcesses = async () => {
return {
id: entry[0],
sessionDurationInMillis: performance.now() - entry[1].firstTick,
};
} as Pick<GameRunning, "id" | "sessionDurationInMillis">;
});
WindowManager.mainWindow.webContents.send(
"on-games-running",
gamesRunning as Pick<GameRunning, "id" | "sessionDurationInMillis">[]
);
WindowManager.mainWindow.webContents.send("on-games-running", gamesRunning);
}
};
function onOpenGame(game: Game) {
const now = performance.now();
gamesPlaytime.set(game.id, {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now,
firstTick: now,
lastSyncTick: now,
@@ -230,16 +232,25 @@ function onOpenGame(game: Game) {
function onTickGame(game: Game) {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get(game.id)!;
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
const delta = now - gamePlaytime.lastTick;
gameRepository.update(game.id, {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
gamesPlaytime.set(game.id, {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
});
@@ -255,7 +266,7 @@ function onTickGame(game: Game) {
gamePromise
.then(() => {
gamesPlaytime.set(game.id, {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastSyncTick: now,
});
@@ -265,8 +276,10 @@ function onTickGame(game: Game) {
}
const onCloseGame = (game: Game) => {
const gamePlaytime = gamesPlaytime.get(game.id)!;
gamesPlaytime.delete(game.id);
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
if (game.remoteId) {
updateGamePlaytime(

View File

@@ -10,7 +10,7 @@ import { Readable } from "node:stream";
import { app, dialog } from "electron";
interface GamePayload {
game_id: number;
game_id: string;
url: string;
save_path: string;
}

View File

@@ -1,43 +1,30 @@
import type { ProfileVisibility, UserDetails } from "@types";
import { User, type ProfileVisibility, type UserDetails } from "@types";
import { HydraApi } from "../hydra-api";
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
export const getUserData = () => {
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
.then(async (me) => {
userAuthRepository.upsert(
{
id: 1,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
userId: me.id,
},
["id"]
db.get<string, User>(levelKeys.user, { valueEncoding: "json" }).then(
(user) => {
return db.put<string, User>(
levelKeys.user,
{
...user,
id: me.id,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
subscription: me.subscription,
},
{ valueEncoding: "json" }
);
}
);
if (me.subscription) {
await userSubscriptionRepository.upsert(
{
id: 1,
subscriptionId: me.subscription?.id || "",
status: me.subscription?.status || "",
planId: me.subscription?.plan.id || "",
planName: me.subscription?.plan.name || "",
expiresAt: me.subscription?.expiresAt || null,
user: { id: 1 },
},
["id"]
);
} else {
await userSubscriptionRepository.delete({ id: 1 });
}
return me;
})
.catch(async (err) => {
@@ -46,15 +33,14 @@ export const getUserData = () => {
return null;
}
logger.error("Failed to get logged user");
const loggedUser = await userAuthRepository.findOne({
where: { id: 1 },
relations: { subscription: true },
const loggedUser = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (loggedUser) {
return {
...loggedUser,
id: loggedUser.userId,
username: "",
bio: "",
email: null,
@@ -64,11 +50,11 @@ export const getUserData = () => {
},
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.subscriptionId,
id: loggedUser.subscription.id,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.planId,
name: loggedUser.subscription.planName,
id: loggedUser.subscription.plan.id,
name: loggedUser.subscription.plan.name,
},
expiresAt: loggedUser.subscription.expiresAt,
}

View File

@@ -13,10 +13,11 @@ import { t } from "i18next";
import path from "node:path";
import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { IsNull, Not } from "typeorm";
import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { slice, sortBy } from "lodash-es";
import type { UserPreferences } from "@types";
import { AuthPage } from "@shared";
export class WindowManager {
@@ -131,9 +132,12 @@ export class WindowManager {
});
this.mainWindow.on("close", async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit();
@@ -211,17 +215,19 @@ export class WindowManager {
}
const updateSystemTray = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
executablePath: Not(IsNull()),
lastTimePlayed: Not(IsNull()),
},
take: 5,
order: {
lastTimePlayed: "DESC",
},
});
const games = await gamesSublevel
.values()
.all()
.then((games) => {
const filteredGames = games.filter(
(game) =>
!game.isDeleted && game.executablePath && game.lastTimePlayed
);
const sortedGames = sortBy(filteredGames, "lastTimePlayed", "DESC");
return slice(sortedGames, 5);
});
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({