mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-22 02:13:59 +00:00
feat: adding initial download sources
This commit is contained in:
@@ -230,7 +230,8 @@
|
||||
"seeding": "Seeding",
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage"
|
||||
"options": "Manage",
|
||||
"extracting": "Extracting files…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
@@ -344,7 +345,8 @@
|
||||
"error_importing_theme": "Error importing theme",
|
||||
"theme_imported": "Theme imported successfully",
|
||||
"enable_friend_request_notifications": "When a friend request is received",
|
||||
"enable_auto_install": "Download updates automatically"
|
||||
"enable_auto_install": "Download updates automatically",
|
||||
"automatically_extract_downloaded_files": "Automatically extract downloaded files"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
@@ -357,7 +359,9 @@
|
||||
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked",
|
||||
"new_friend_request_description": "You have received a new friend request",
|
||||
"new_friend_request_title": "New friend request"
|
||||
"new_friend_request_title": "New friend request",
|
||||
"extraction_complete": "Extraction complete",
|
||||
"game_extracted": "{{title}} extracted successfully"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
|
||||
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
|
||||
"game_removed_from_favorites": "Jogo removido dos favoritos",
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos"
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos",
|
||||
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -219,7 +220,8 @@
|
||||
"seeding": "Semeando",
|
||||
"stop_seeding": "Parar de semear",
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Gerenciar"
|
||||
"options": "Gerenciar",
|
||||
"extracting": "Extraindo arquivos…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
@@ -342,7 +344,9 @@
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão",
|
||||
"new_friend_request_title": "Novo pedido de amizade",
|
||||
"new_friend_request_description": "Você recebeu um novo pedido de amizade"
|
||||
"new_friend_request_description": "Você recebeu um novo pedido de amizade",
|
||||
"extraction_complete": "Extração concluída",
|
||||
"game_extracted": "{{title}} extraído com sucesso"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CloudSync } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { GameShop } from "@types";
|
||||
import { t } from "i18next";
|
||||
import { format } from "date-fns";
|
||||
import i18next, { t } from "i18next";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
const uploadSaveGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -10,13 +10,15 @@ const uploadSaveGame = async (
|
||||
shop: GameShop,
|
||||
downloadOptionTitle: string | null
|
||||
) => {
|
||||
const { language } = i18next;
|
||||
|
||||
return CloudSync.uploadSaveGame(
|
||||
objectId,
|
||||
shop,
|
||||
downloadOptionTitle,
|
||||
t("backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const createDownloadSource = async (
|
||||
const createDownloadSources = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url: string
|
||||
urls: string[]
|
||||
) => {
|
||||
await HydraApi.post("/profile/download-sources", {
|
||||
url,
|
||||
urls,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("createDownloadSource", createDownloadSource);
|
||||
registerEvent("createDownloadSources", createDownloadSources);
|
||||
@@ -90,7 +90,7 @@ import "./themes/get-custom-theme-by-id";
|
||||
import "./themes/get-active-custom-theme";
|
||||
import "./themes/close-editor-window";
|
||||
import "./themes/toggle-custom-theme";
|
||||
import "./download-sources/create-download-source";
|
||||
import "./download-sources/create-download-sources";
|
||||
import "./download-sources/remove-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
@@ -12,7 +12,15 @@ const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
|
||||
const {
|
||||
objectId,
|
||||
title,
|
||||
shop,
|
||||
downloadPath,
|
||||
downloader,
|
||||
uri,
|
||||
automaticallyExtract,
|
||||
} = payload;
|
||||
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
@@ -74,6 +82,8 @@ const startGameDownload = async (
|
||||
shouldSeed: false,
|
||||
timestamp: Date.now(),
|
||||
queued: true,
|
||||
extracting: false,
|
||||
automaticallyExtract,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -23,10 +23,6 @@ const updateUserPreferences = async (
|
||||
patchUserProfile({ language: preferences.language }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!preferences.downloadsPath) {
|
||||
preferences.downloadsPath = null;
|
||||
}
|
||||
|
||||
await db.put<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import knex from "knex";
|
||||
import { databasePath } from "./constants";
|
||||
import { app } from "electron";
|
||||
|
||||
export const knexClient = knex({
|
||||
debug: !app.isPackaged,
|
||||
client: "better-sqlite3",
|
||||
connection: {
|
||||
filename: databasePath,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
const config = {
|
||||
development: {
|
||||
migrations: {
|
||||
extension: "ts",
|
||||
stub: "migrations/migration.stub",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -13,6 +13,5 @@ export const levelKeys = {
|
||||
downloads: "downloads",
|
||||
userPreferences: "userPreferences",
|
||||
language: "language",
|
||||
sqliteMigrationDone: "sqliteMigrationDone",
|
||||
screenState: "screenState",
|
||||
};
|
||||
|
||||
168
src/main/main.ts
168
src/main/main.ts
@@ -1,4 +1,4 @@
|
||||
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
|
||||
import { DownloadManager, Ludusavi, startMainLoop } from "./services";
|
||||
import { RealDebridClient } from "./services/download/real-debrid";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
@@ -6,26 +6,17 @@ import { Aria2 } from "./services/aria2";
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import {
|
||||
gameAchievementsSublevel,
|
||||
gamesSublevel,
|
||||
levelKeys,
|
||||
db,
|
||||
} from "./level";
|
||||
import { Auth, User, type UserPreferences } from "@types";
|
||||
import { knexClient } from "./knex-client";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { TorBoxClient } from "./services/download/torbox";
|
||||
|
||||
export const loadState = async () => {
|
||||
const userPreferences = await migrateFromSqlite().then(async () => {
|
||||
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
return db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await import("./events");
|
||||
|
||||
@@ -52,6 +43,15 @@ export const loadState = async () => {
|
||||
return sortBy(games, "timestamp", "DESC");
|
||||
});
|
||||
|
||||
downloads.forEach((download) => {
|
||||
if (download.extracting) {
|
||||
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), {
|
||||
...download,
|
||||
extracting: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
|
||||
|
||||
const downloadsToSeed = downloads.filter(
|
||||
@@ -66,137 +66,3 @@ export const loadState = async () => {
|
||||
|
||||
startMainLoop();
|
||||
};
|
||||
|
||||
const migrateFromSqlite = async () => {
|
||||
const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone);
|
||||
|
||||
if (sqliteMigrationDone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrateGames = knexClient("game")
|
||||
.select("*")
|
||||
.then((games) => {
|
||||
return gamesSublevel.batch(
|
||||
games.map((game) => ({
|
||||
type: "put",
|
||||
key: levelKeys.game(game.shop, game.objectID),
|
||||
value: {
|
||||
objectId: game.objectID,
|
||||
shop: game.shop,
|
||||
title: game.title,
|
||||
iconUrl: game.iconUrl,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
remoteId: game.remoteId,
|
||||
winePrefixPath: game.winePrefixPath,
|
||||
launchOptions: game.launchOptions,
|
||||
executablePath: game.executablePath,
|
||||
isDeleted: game.isDeleted === 1,
|
||||
},
|
||||
}))
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("Games migrated successfully");
|
||||
});
|
||||
|
||||
const migrateUserPreferences = knexClient("user_preferences")
|
||||
.select("*")
|
||||
.then(async (userPreferences) => {
|
||||
if (userPreferences.length > 0) {
|
||||
const { realDebridApiToken, ...rest } = userPreferences[0];
|
||||
|
||||
await db.put<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
...rest,
|
||||
realDebridApiToken,
|
||||
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,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (rest.language) {
|
||||
await db.put<string, string>(levelKeys.language, rest.language, {
|
||||
valueEncoding: "utf-8",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.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: users[0].accessToken,
|
||||
refreshToken: users[0].refreshToken,
|
||||
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
|
||||
},
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("User data migrated successfully");
|
||||
});
|
||||
|
||||
return Promise.allSettled([
|
||||
migrateGames,
|
||||
migrateUserPreferences,
|
||||
migrateAchievements,
|
||||
migrateUser,
|
||||
]);
|
||||
};
|
||||
|
||||
38
src/main/services/7zip.ts
Normal file
38
src/main/services/7zip.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { app } from "electron";
|
||||
import cp from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
export const binaryName = {
|
||||
linux: "7zzs",
|
||||
darwin: "7zz",
|
||||
win32: "7zr.exe",
|
||||
};
|
||||
|
||||
export class _7Zip {
|
||||
private static readonly binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, binaryName[process.platform])
|
||||
: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"binaries",
|
||||
binaryName[process.platform]
|
||||
);
|
||||
|
||||
public static extractFile(
|
||||
filePath: string,
|
||||
outputPath: string,
|
||||
cb: () => void
|
||||
) {
|
||||
const child = cp.spawn(this.binaryPath, [
|
||||
"x",
|
||||
filePath,
|
||||
"-o" + outputPath,
|
||||
"-y",
|
||||
]);
|
||||
|
||||
child.on("exit", () => {
|
||||
cb();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,15 @@ import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import { app } from "electron";
|
||||
|
||||
export const startAria2 = () => {};
|
||||
|
||||
export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
private static readonly binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
this.binaryPath,
|
||||
[
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Downloader, DownloadError } from "@shared";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import {
|
||||
publishDownloadCompleteNotification,
|
||||
publishExtractionCompleteNotification,
|
||||
} from "../notifications";
|
||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||
import {
|
||||
GofileApi,
|
||||
@@ -22,9 +25,11 @@ import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { _7Zip } from "../7zip";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
private static readonly extensionsToExtract = [".rar", ".zip", ".7z"];
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
@@ -150,13 +155,47 @@ export class DownloadManager {
|
||||
queued: false,
|
||||
});
|
||||
} else {
|
||||
const shouldExtract =
|
||||
download.downloader !== Downloader.Torrent &&
|
||||
this.extensionsToExtract.some((ext) =>
|
||||
download.folderName?.endsWith(ext)
|
||||
) &&
|
||||
download.automaticallyExtract;
|
||||
|
||||
downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "complete",
|
||||
shouldSeed: false,
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
});
|
||||
|
||||
if (shouldExtract) {
|
||||
_7Zip.extractFile(
|
||||
path.join(download.downloadPath, download.folderName!),
|
||||
path.join(
|
||||
download.downloadPath,
|
||||
path.parse(download.folderName!).name
|
||||
),
|
||||
async () => {
|
||||
const download = await downloadsSublevel.get(gameId);
|
||||
|
||||
downloadsSublevel.put(gameId, {
|
||||
...download!,
|
||||
extracting: false,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-complete",
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./main-loop";
|
||||
export * from "./hydra-api";
|
||||
export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
export * from "./7zip";
|
||||
|
||||
@@ -128,6 +128,17 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||
new Notification({
|
||||
title: t("extraction_complete", { ns: "notifications" }),
|
||||
body: t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
export const publishNewAchievementNotification = async (info: {
|
||||
achievements: { displayName: string; iconUrl: string }[];
|
||||
unlockedAchievementCount: number;
|
||||
|
||||
@@ -6,9 +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 i18next, { t } from "i18next";
|
||||
import { CloudSync } from "./cloud-sync";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } 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 ""}'`,
|
||||
@@ -229,6 +229,8 @@ function onOpenGame(game: Game) {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||
|
||||
const { language } = i18next;
|
||||
|
||||
if (game.automaticCloudSync) {
|
||||
CloudSync.uploadSaveGame(
|
||||
game.objectId,
|
||||
@@ -236,7 +238,7 @@ function onOpenGame(game: Game) {
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -296,6 +298,8 @@ const onCloseGame = (game: Game) => {
|
||||
)!;
|
||||
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
|
||||
|
||||
const { language } = i18next;
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(
|
||||
game,
|
||||
@@ -310,7 +314,7 @@ const onCloseGame = (game: Game) => {
|
||||
null,
|
||||
t("automatic_backup_from", {
|
||||
ns: "game_details",
|
||||
date: format(new Date(), "dd/MM/yyyy"),
|
||||
date: formatDate(new Date(), language),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
/* Download sources */
|
||||
putDownloadSource: (objectIds: string[]) =>
|
||||
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||
createDownloadSource: (url: string) =>
|
||||
ipcRenderer.invoke("createDownloadSource", url),
|
||||
createDownloadSources: (urls: string[]) =>
|
||||
ipcRenderer.invoke("createDownloadSources", urls),
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) =>
|
||||
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
@@ -200,6 +200,15 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-achievement-unlocked", listener);
|
||||
},
|
||||
onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => cb(shop, objectId);
|
||||
ipcRenderer.on("on-extraction-complete", listener);
|
||||
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
|
||||
},
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
|
||||
@@ -143,6 +143,10 @@ export function App() {
|
||||
const existingDownloadSources: DownloadSource[] =
|
||||
await downloadSourcesTable.toArray();
|
||||
|
||||
window.electron.createDownloadSources(
|
||||
existingDownloadSources.map((source) => source.url)
|
||||
);
|
||||
|
||||
await Promise.allSettled(
|
||||
downloadSources.map(async (source) => {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
5
src/renderer/src/declaration.d.ts
vendored
5
src/renderer/src/declaration.d.ts
vendored
@@ -160,12 +160,15 @@ declare global {
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
createDownloadSource: (url: string) => Promise<void>;
|
||||
createDownloadSources: (urls: string[]) => Promise<void>;
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
|
||||
getDownloadSources: () => Promise<
|
||||
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { formatDate, getDateLocale } from "@shared";
|
||||
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||
import type { FormatDistanceOptions } from "date-fns";
|
||||
import {
|
||||
ptBR,
|
||||
enUS,
|
||||
es,
|
||||
fr,
|
||||
pl,
|
||||
hu,
|
||||
tr,
|
||||
ru,
|
||||
it,
|
||||
be,
|
||||
zhCN,
|
||||
da,
|
||||
} from "date-fns/locale";
|
||||
import { enUS } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useDate() {
|
||||
@@ -21,22 +9,6 @@ export function useDate() {
|
||||
|
||||
const { language } = i18n;
|
||||
|
||||
const getDateLocale = () => {
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("hu")) return hu;
|
||||
if (language.startsWith("pl")) return pl;
|
||||
if (language.startsWith("tr")) return tr;
|
||||
if (language.startsWith("ru")) return ru;
|
||||
if (language.startsWith("it")) return it;
|
||||
if (language.startsWith("be")) return be;
|
||||
if (language.startsWith("zh")) return zhCN;
|
||||
if (language.startsWith("da")) return da;
|
||||
|
||||
return enUS;
|
||||
};
|
||||
|
||||
return {
|
||||
formatDistance: (
|
||||
date: string | number | Date,
|
||||
@@ -46,7 +18,7 @@ export function useDate() {
|
||||
try {
|
||||
return formatDistance(date, baseDate, {
|
||||
...options,
|
||||
locale: getDateLocale(),
|
||||
locale: getDateLocale(language),
|
||||
});
|
||||
} catch (err) {
|
||||
return "";
|
||||
@@ -61,7 +33,7 @@ export function useDate() {
|
||||
try {
|
||||
return formatDistance(subMilliseconds(new Date(), millis), baseDate, {
|
||||
...options,
|
||||
locale: getDateLocale(),
|
||||
locale: getDateLocale(language),
|
||||
});
|
||||
} catch (err) {
|
||||
return "";
|
||||
@@ -69,18 +41,13 @@ export function useDate() {
|
||||
},
|
||||
|
||||
formatDateTime: (date: number | Date | string): string => {
|
||||
const locale = getDateLocale();
|
||||
const locale = getDateLocale(language);
|
||||
return format(
|
||||
date,
|
||||
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy HH:mm"
|
||||
);
|
||||
},
|
||||
|
||||
formatDate: (date: number | Date | string): string => {
|
||||
if (isNaN(new Date(date).getDate())) return "N/A";
|
||||
|
||||
const locale = getDateLocale();
|
||||
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||
},
|
||||
formatDate: (date: number | Date | string) => formatDate(date, language),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,14 +18,11 @@ export function Pagination({
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// Number of visible pages
|
||||
const visiblePages = 3;
|
||||
|
||||
// Calculate the start and end of the visible range
|
||||
let startPage = Math.max(1, page - 1); // Shift range slightly back
|
||||
let startPage = Math.max(1, page - 1);
|
||||
let endPage = startPage + visiblePages - 1;
|
||||
|
||||
// Adjust the range if we're near the start or end
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - visiblePages + 1);
|
||||
@@ -33,7 +30,6 @@ export function Pagination({
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
@@ -45,7 +41,6 @@ export function Pagination({
|
||||
|
||||
{page > 2 && (
|
||||
<>
|
||||
{/* initial page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(1)}
|
||||
@@ -55,14 +50,12 @@ export function Pagination({
|
||||
{1}
|
||||
</Button>
|
||||
|
||||
{/* ellipsis */}
|
||||
<div className="pagination__ellipsis">
|
||||
<span className="pagination__ellipsis-text">...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page Buttons */}
|
||||
{Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, i) => startPage + i
|
||||
@@ -79,12 +72,10 @@ export function Pagination({
|
||||
|
||||
{page < totalPages - 1 && (
|
||||
<>
|
||||
{/* ellipsis */}
|
||||
<div className="pagination__ellipsis">
|
||||
<span className="pagination__ellipsis-text">...</span>
|
||||
</div>
|
||||
|
||||
{/* last page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
@@ -96,7 +87,6 @@ export function Pagination({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
|
||||
@@ -96,6 +96,10 @@ export function DownloadGroup({
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const seedingStatus = seedingMap.get(game.id);
|
||||
|
||||
if (download.extracting) {
|
||||
return <p>{t("extracting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ export default function Downloads() {
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
}, []);
|
||||
|
||||
const unsubscribe = window.electron.onExtractionComplete(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [updateLibrary]);
|
||||
|
||||
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
|
||||
window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => {
|
||||
@@ -67,7 +73,7 @@ export default function Downloads() {
|
||||
if (!next.download) return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id)
|
||||
if (lastPacket?.gameId === next.id || next.download.extracting)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./cloud-sync-modal.scss";
|
||||
import { formatBytes } from "@shared";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ClockIcon,
|
||||
DeviceDesktopIcon,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import { useAppSelector, useDate, useToast } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosProgressEvent } from "axios";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
@@ -29,6 +28,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate, formatDateTime } = useDate();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
backupPreview,
|
||||
@@ -205,7 +206,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
<h3>
|
||||
{artifact.label ??
|
||||
t("backup_from", {
|
||||
date: format(artifact.createdAt, "dd/MM/yyyy"),
|
||||
date: formatDate(artifact.createdAt),
|
||||
})}
|
||||
</h3>
|
||||
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||
@@ -223,7 +224,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
|
||||
<span className="cloud-sync-modal__artifact-meta">
|
||||
<ClockIcon size={14} />
|
||||
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
|
||||
{formatDateTime(artifact.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ export default function GameDetails() {
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => {
|
||||
const response = await startDownload({
|
||||
repackId: repack.id,
|
||||
@@ -108,6 +109,7 @@ export default function GameDetails() {
|
||||
shop,
|
||||
downloadPath,
|
||||
uri: selectRepackUri(repack, downloader),
|
||||
automaticallyExtract: automaticallyExtract,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
CheckboxField,
|
||||
Link,
|
||||
Modal,
|
||||
TextField,
|
||||
} from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||
import type { GameRepack } from "@types";
|
||||
@@ -14,7 +20,8 @@ export interface DownloadSettingsModalProps {
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
@@ -32,6 +39,8 @@ export function DownloadSettingsModal({
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<number | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [automaticExtractionEnabled, setAutomaticExtractionEnabled] =
|
||||
useState(true);
|
||||
const [selectedDownloader, setSelectedDownloader] =
|
||||
useState<Downloader | null>(null);
|
||||
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
||||
@@ -72,6 +81,21 @@ export function DownloadSettingsModal({
|
||||
return getDownloadersForUris(repack?.uris ?? []);
|
||||
}, [repack?.uris]);
|
||||
|
||||
const getDefaultDownloader = useCallback(
|
||||
(availableDownloaders: Downloader[]) => {
|
||||
if (availableDownloaders.includes(Downloader.TorBox)) {
|
||||
return Downloader.TorBox;
|
||||
}
|
||||
|
||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||
return Downloader.RealDebrid;
|
||||
}
|
||||
|
||||
return availableDownloaders[0];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences?.downloadsPath) {
|
||||
setSelectedPath(userPreferences.downloadsPath);
|
||||
@@ -89,13 +113,9 @@ export function DownloadSettingsModal({
|
||||
return true;
|
||||
});
|
||||
|
||||
/* Gives preference to TorBox */
|
||||
const selectedDownloader = filteredDownloaders.includes(Downloader.TorBox)
|
||||
? Downloader.TorBox
|
||||
: filteredDownloaders[0];
|
||||
|
||||
setSelectedDownloader(selectedDownloader ?? null);
|
||||
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
|
||||
}, [
|
||||
getDefaultDownloader,
|
||||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
@@ -122,7 +142,8 @@ export function DownloadSettingsModal({
|
||||
const response = await startDownload(
|
||||
repack,
|
||||
selectedDownloader!,
|
||||
selectedPath
|
||||
selectedPath,
|
||||
automaticExtractionEnabled
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -217,6 +238,16 @@ export function DownloadSettingsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedDownloader !== Downloader.Torrent && (
|
||||
<CheckboxField
|
||||
label={t("automatically_extract_downloaded_files")}
|
||||
checked={automaticExtractionEnabled}
|
||||
onChange={() =>
|
||||
setAutomaticExtractionEnabled(!automaticExtractionEnabled)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleStartClick}
|
||||
disabled={
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface RepacksModalProps {
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
downloadPath: string,
|
||||
automaticallyExtract: boolean
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function AddDownloadSourceModal({
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.createDownloadSource(url);
|
||||
window.electron.createDownloadSources([url]);
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
|
||||
@@ -139,9 +139,9 @@ export function SettingsGeneral() {
|
||||
}))}
|
||||
/>
|
||||
|
||||
<p className="settings-general__notifications-title">
|
||||
<h2 className="settings-general__notifications-title">
|
||||
{t("notifications")}
|
||||
</p>
|
||||
</h2>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_download_notifications")}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import {
|
||||
ptBR,
|
||||
enUS,
|
||||
es,
|
||||
fr,
|
||||
pl,
|
||||
hu,
|
||||
tr,
|
||||
ru,
|
||||
it,
|
||||
be,
|
||||
zhCN,
|
||||
da,
|
||||
} from "date-fns/locale";
|
||||
|
||||
import { charMap } from "./char-map";
|
||||
import { Downloader } from "./constants";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export * from "./constants";
|
||||
|
||||
@@ -124,3 +140,29 @@ export const steamUrlBuilder = {
|
||||
icon: (objectId: string, clientIcon: string) =>
|
||||
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
|
||||
};
|
||||
|
||||
export const getDateLocale = (language: string) => {
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
if (language.startsWith("hu")) return hu;
|
||||
if (language.startsWith("pl")) return pl;
|
||||
if (language.startsWith("tr")) return tr;
|
||||
if (language.startsWith("ru")) return ru;
|
||||
if (language.startsWith("it")) return it;
|
||||
if (language.startsWith("be")) return be;
|
||||
if (language.startsWith("zh")) return zhCN;
|
||||
if (language.startsWith("da")) return da;
|
||||
|
||||
return enUS;
|
||||
};
|
||||
|
||||
export const formatDate = (
|
||||
date: number | Date | string,
|
||||
language: string
|
||||
): string => {
|
||||
if (isNaN(new Date(date).getDate())) return "N/A";
|
||||
|
||||
const locale = getDateLocale(language);
|
||||
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ export type DownloadStatus =
|
||||
| "error"
|
||||
| "complete"
|
||||
| "seeding"
|
||||
| "removed";
|
||||
| "removed"
|
||||
| "extracting";
|
||||
|
||||
export interface DownloadProgress {
|
||||
downloadSpeed: number;
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface StartGameDownloadPayload {
|
||||
uri: string;
|
||||
downloadPath: string;
|
||||
downloader: Downloader;
|
||||
automaticallyExtract: boolean;
|
||||
}
|
||||
|
||||
export interface UserFriend {
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface Download {
|
||||
status: DownloadStatus | null;
|
||||
queued: boolean;
|
||||
timestamp: number;
|
||||
extracting: boolean;
|
||||
automaticallyExtract: boolean;
|
||||
}
|
||||
|
||||
export interface GameAchievement {
|
||||
|
||||
Reference in New Issue
Block a user