resolved conflicts

This commit is contained in:
Kelvin
2025-03-10 23:54:03 -03:00
114 changed files with 2798 additions and 624 deletions

View File

@@ -23,23 +23,21 @@ const saveAchievementsOnLocal = async (
return gameAchievementsSublevel
.get(levelKey)
.then(async (gameAchievement) => {
if (gameAchievement) {
await gameAchievementsSublevel.put(levelKey, {
...gameAchievement,
unlockedAchievements: unlockedAchievements,
});
await gameAchievementsSublevel.put(levelKey, {
achievements: gameAchievement?.achievements ?? [],
unlockedAchievements: unlockedAchievements,
});
if (!sendUpdateEvent) return;
if (!sendUpdateEvent) return;
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
}
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
});
};
@@ -133,7 +131,7 @@ export const mergeAchievements = async (
);
})
.catch((err) => {
if (err! instanceof SubscriptionRequiredError) {
if (err instanceof SubscriptionRequiredError) {
achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription",
game.objectId,

View File

@@ -6,7 +6,7 @@ import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchivements = async (game: Game) => {
export const updateLocalUnlockedAchievements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =

View File

@@ -0,0 +1,112 @@
import { levelKeys, gamesSublevel, db } from "@main/level";
import { app } from "electron";
import path from "node:path";
import * as tar from "tar";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import type { GameShop, User } from "@types";
import { backupsPath } from "@main/constants";
import { HydraApi } from "./hydra-api";
import { normalizePath } from "@main/helpers";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import axios from "axios";
import { Ludusavi } from "./ludusavi";
import { isFuture, isToday } from "date-fns";
import { SubscriptionRequiredError } from "@shared";
export class CloudSync {
private static async bundleBackup(
shop: GameShop,
objectId: string,
winePrefix: string | null
) {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
// Remove existing backup
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, { recursive: true });
}
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
return tarLocation;
}
public static async uploadSaveGame(
objectId: string,
shop: GameShop,
downloadOptionTitle: string | null,
label?: string
) {
const hasActiveSubscription = await db
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
.then((user) => {
const expiresAt = user?.subscription?.expiresAt;
return expiresAt && (isFuture(expiresAt) || isToday(expiresAt));
});
if (!hasActiveSubscription) {
throw new SubscriptionRequiredError();
}
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const bundleLocation = await this.bundleBackup(
shop,
objectId,
game?.winePrefixPath ?? null
);
const stat = await fs.promises.stat(bundleLocation);
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: normalizePath(app.getPath("home")),
downloadOptionTitle,
platform: os.platform(),
label,
});
const fileBuffer = await fs.promises.readFile(bundleLocation);
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
logger.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to remove tar file", err);
throw err;
}
});
}
}

View File

@@ -1,28 +0,0 @@
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

@@ -2,7 +2,13 @@ import { Downloader, DownloadError } from "@shared";
import { WindowManager } from "../window-manager";
import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types";
import { GofileApi, QiwiApi, DatanodesApi, MediafireApi } from "../hosters";
import {
GofileApi,
QiwiApi,
DatanodesApi,
MediafireApi,
PixelDrainApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
LibtorrentPayload,
@@ -219,8 +225,10 @@ export class DownloadManager {
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
}
}
static async resumeDownload(download: Download) {
@@ -228,14 +236,17 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: downloadKey,
});
WindowManager.mainWindow?.setProgressBar(-1);
await PythonRPC.rpc
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
}
@@ -278,11 +289,12 @@ export class DownloadManager {
}
case Downloader.PixelDrain: {
const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
return {
action: "start",
game_id: downloadId,
url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`,
url: downloadUrl,
save_path: download.downloadPath,
};
}

View File

@@ -6,6 +6,7 @@ import type {
TorBoxAddTorrentRequest,
TorBoxRequestLinkRequest,
} from "@types";
import { appVersion } from "@main/constants";
export class TorBoxClient {
private static instance: AxiosInstance;
@@ -18,6 +19,7 @@ export class TorBoxClient {
baseURL: this.baseURL,
headers: {
Authorization: `Bearer ${apiToken}`,
"User-Agent": `Hydra/${appVersion}`,
},
});
}

View File

@@ -1,47 +1,71 @@
import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
export class DatanodesApi {
private static readonly session = axios.create({});
private static readonly jar = new CookieJar();
private static readonly session = wrapper(
axios.create({
jar: DatanodesApi.jar,
withCredentials: true,
})
);
public static async getDownloadUrl(downloadUrl: string): Promise<string> {
const parsedUrl = new URL(downloadUrl);
const pathSegments = parsedUrl.pathname.split("/");
try {
const parsedUrl = new URL(downloadUrl);
const pathSegments = parsedUrl.pathname.split("/").filter(Boolean);
const fileCode = pathSegments[0];
const fileCode = decodeURIComponent(pathSegments[1]);
const fileName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
rand: "",
referer: "https://datanodes.to/download",
method_free: "Free Download >>",
method_premium: "",
adblock_detected: "",
});
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
method_free: "Free Download >>",
dl: "1",
});
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: `lang=english; file_name=${fileName}; file_code=${fileCode};`,
Host: "datanodes.to",
Origin: "https://datanodes.to",
Referer: "https://datanodes.to/download",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to",
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
}
);
if (response.status === 302) {
return response.headers["location"];
}
);
if (response.status === 302) {
return response.headers["location"];
if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(response.data.url);
}
const htmlContent = String(response.data);
if (!htmlContent) {
throw new Error("Empty response received");
}
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
if (downloadLinkMatch) {
return downloadLinkMatch[1];
}
throw new Error("Failed to get the download link");
} catch (error) {
console.error("Error fetching download URL:", error);
throw error;
}
return "";
}
}

View File

@@ -2,3 +2,4 @@ export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";

View File

@@ -0,0 +1,42 @@
import axios from "axios";
export class PixelDrainApi {
private static readonly browserHeaders = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
DNT: "1",
Connection: "keep-alive",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
};
public static async getDownloadUrl(fileId: string): Promise<string> {
try {
const response = await axios.get(`https://pd.cybar.xyz/${fileId}`, {
headers: this.browserHeaders,
maxRedirects: 0,
validateStatus: (status) =>
status === 301 || status === 302 || status === 200,
});
if (
response.headers.location ||
response.status === 301 ||
response.status === 302
) {
return response.headers.location;
}
throw new Error(`No redirect URL found (status: ${response.status})`);
} catch (error) {
console.error("Error fetching PixelDrain URL:", error);
throw error;
}
}
}

View File

@@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -32,8 +31,9 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly secondsToMilliseconds = (seconds: number) =>
seconds * 1000;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
}
private static userAuth: HydraApiUserAuth = {
authToken: "",
@@ -81,8 +81,8 @@ export class HydraApi {
db.put<string, Auth>(
levelKeys.auth,
{
accessToken: Crypto.encrypt(accessToken),
refreshToken: Crypto.encrypt(refreshToken),
accessToken,
refreshToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }
@@ -204,12 +204,8 @@ export class HydraApi {
const user = result.at(1) as User | undefined;
this.userAuth = {
authToken: userAuth?.accessToken
? Crypto.decrypt(userAuth.accessToken)
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
authToken: userAuth?.accessToken ?? "",
refreshToken: userAuth?.refreshToken ?? "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: user?.subscription
? { expiresAt: user.subscription?.expiresAt }
@@ -258,7 +254,7 @@ export class HydraApi {
levelKeys.auth,
{
...auth,
accessToken: Crypto.encrypt(accessToken),
accessToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }

View File

@@ -1,4 +1,3 @@
export * from "./crypto";
export * from "./logger";
export * from "./steam";
export * from "./steam-250";
@@ -8,3 +7,4 @@ export * from "./process-watcher";
export * from "./main-loop";
export * from "./hydra-api";
export * from "./ludusavi";
export * from "./cloud-sync";

View File

@@ -24,7 +24,7 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...localGame,
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
@@ -39,7 +39,7 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,

View File

@@ -6,6 +6,9 @@ import axios from "axios";
import { exec } from "child_process";
import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { t } from "i18next";
import { CloudSync } from "./cloud-sync";
import { format } from "date-fns";
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 ""}'`,
@@ -225,6 +228,18 @@ function onOpenGame(game: Game) {
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()).catch(() => {});
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
game.objectId,
game.shop,
null,
t("automatic_backup_from", {
ns: "game_details",
date: format(new Date(), "dd/MM/yyyy"),
})
);
}
} else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
@@ -287,6 +302,18 @@ const onCloseGame = (game: Game) => {
performance.now() - gamePlaytime.lastSyncTick,
game.lastTimePlayed!
).catch(() => {});
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
game.objectId,
game.shop,
null,
t("automatic_backup_from", {
ns: "game_details",
date: format(new Date(), "dd/MM/yyyy"),
})
);
}
} else {
createGame(game).catch(() => {});
}

View File

@@ -24,6 +24,8 @@ import { isStaging } from "@main/constants";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
@@ -36,7 +38,7 @@ export class WindowManager {
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
color: "#00000000",
height: 34,
},
webPreferences: {
@@ -46,6 +48,23 @@ export class WindowManager {
show: false,
};
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
);
} else {
this.mainWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash,
}
);
}
}
private static async saveScreenConfig({
...configScreenWhenClosed
}: {
@@ -66,6 +85,7 @@ export class WindowManager {
});
return data ?? {};
}
private static updateInitialConfig(
newConfig: Partial<Electron.BrowserWindowConstructorOptions>
) {
@@ -75,23 +95,6 @@ export class WindowManager {
};
}
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
);
} else {
this.mainWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash,
}
);
}
}
public static async createMainWindow() {
if (this.mainWindow) return;
@@ -261,6 +264,87 @@ export class WindowManager {
}
}
public static openEditorWindow(themeId: string) {
if (this.mainWindow) {
const existingWindow = this.editorWindows.get(themeId);
if (existingWindow) {
if (existingWindow.isMinimized()) {
existingWindow.restore();
}
existingWindow.focus();
return;
}
const editorWindow = new BrowserWindow({
width: 600,
height: 720,
minWidth: 600,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.editorWindows.set(themeId, editorWindow);
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash: `theme-editor?themeId=${themeId}`,
});
}
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (isStaging) {
editorWindow.webContents.openDevTools();
}
});
editorWindow.webContents.on("before-input-event", (event, input) => {
if (input.key === "F12") {
event.preventDefault();
this.mainWindow?.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}
}
public static closeEditorWindow(themeId?: string) {
if (themeId) {
const editorWindow = this.editorWindows.get(themeId);
if (editorWindow) {
editorWindow.close();
}
} else {
this.editorWindows.forEach((editorWindow) => {
editorWindow.close();
});
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);