Skip to content

Commit c4c401e

Browse files
Merge pull request #1495 from hydralauncher/feat/automatic-cloud-sync
feat: adding automatic cloud sync
2 parents af2896e + 864ff00 commit c4c401e

22 files changed

+272
-101
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hydralauncher",
3-
"version": "3.2.3",
3+
"version": "3.3.0",
44
"description": "Hydra",
55
"main": "./out/main/index.js",
66
"author": "Los Broxas",

src/locales/en/translation.json

+2
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@
178178
"manage_files_description": "Manage which files will be backed up and restored",
179179
"select_folder": "Select folder",
180180
"backup_from": "Backup from {{date}}",
181+
"automatic_backup_from": "Automatic backup from {{date}}",
182+
"enable_automatic_cloud_sync": "Enable automatic cloud sync",
181183
"custom_backup_location_set": "Custom backup location set",
182184
"no_directory_selected": "No directory selected",
183185
"no_write_permission": "Cannot download into this directory. Click here to learn more.",

src/locales/es/translation.json

+2
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@
174174
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados",
175175
"select_folder": "Seleccionar carpeta",
176176
"backup_from": "Copia de seguridad de {{date}}",
177+
"automatic_backup_from": "Copia de seguridad automática de {{date}}",
178+
"enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube",
177179
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
178180
"clear": "Limpiar",
179181
"no_directory_selected": "No se seleccionó un directorio",

src/locales/pt-BR/translation.json

+2
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@
165165
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",
166166
"achievements_not_sync": "Veja como exibir suas conquistas no perfil",
167167
"backup_from": "Backup de {{date}}",
168+
"automatic_backup_from": "Backup automático de {{date}}",
169+
"enable_automatic_cloud_sync": "Habilitar sincronização automática na nuvem",
168170
"custom_backup_location_set": "Localização customizada selecionada",
169171
"select_folder": "Selecione a pasta",
170172
"manage_files_description": "Gerencie quais arquivos serão feitos backup",

src/locales/ru/translation.json

+2
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@
178178
"manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться",
179179
"select_folder": "Выбрать папку",
180180
"backup_from": "Резервная копия от {{date}}",
181+
"automatic_backup_from": "Автоматическая резервная копия от {{date}}",
182+
"enable_automatic_cloud_sync": "Включить автоматическую синхронизацию в облаке",
181183
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
182184
"no_directory_selected": "Не выбран каталог",
183185
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
+10-92
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,24 @@
1-
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
1+
import { CloudSync } from "@main/services";
22
import { registerEvent } from "../register-event";
3-
import fs from "node:fs";
4-
import path from "node:path";
5-
import * as tar from "tar";
6-
import crypto from "node:crypto";
73
import type { GameShop } from "@types";
8-
import axios from "axios";
9-
import os from "node:os";
10-
import { backupsPath } from "@main/constants";
11-
import { app } from "electron";
12-
import { normalizePath } from "@main/helpers";
13-
import { gamesSublevel, levelKeys } from "@main/level";
14-
15-
const bundleBackup = async (
16-
shop: GameShop,
17-
objectId: string,
18-
winePrefix: string | null
19-
) => {
20-
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
21-
22-
// Remove existing backup
23-
if (fs.existsSync(backupPath)) {
24-
fs.rmSync(backupPath, { recursive: true });
25-
}
26-
27-
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
28-
29-
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
30-
31-
await tar.create(
32-
{
33-
gzip: false,
34-
file: tarLocation,
35-
cwd: backupPath,
36-
},
37-
["."]
38-
);
39-
40-
return tarLocation;
41-
};
4+
import { t } from "i18next";
5+
import { format } from "date-fns";
426

437
const uploadSaveGame = async (
448
_event: Electron.IpcMainInvokeEvent,
459
objectId: string,
4610
shop: GameShop,
4711
downloadOptionTitle: string | null
4812
) => {
49-
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
50-
51-
const bundleLocation = await bundleBackup(
52-
shop,
13+
return CloudSync.uploadSaveGame(
5314
objectId,
54-
game?.winePrefixPath ?? null
15+
shop,
16+
downloadOptionTitle,
17+
t("backup_from", {
18+
ns: "game_details",
19+
date: format(new Date(), "dd/MM/yyyy"),
20+
})
5521
);
56-
57-
fs.stat(bundleLocation, async (err, stat) => {
58-
if (err) {
59-
logger.error("Failed to get zip file stats", err);
60-
throw err;
61-
}
62-
63-
const { uploadUrl } = await HydraApi.post<{
64-
id: string;
65-
uploadUrl: string;
66-
}>("/profile/games/artifacts", {
67-
artifactLengthInBytes: stat.size,
68-
shop,
69-
objectId,
70-
hostname: os.hostname(),
71-
homeDir: normalizePath(app.getPath("home")),
72-
downloadOptionTitle,
73-
platform: os.platform(),
74-
});
75-
76-
fs.readFile(bundleLocation, async (err, fileBuffer) => {
77-
if (err) {
78-
logger.error("Failed to read zip file", err);
79-
throw err;
80-
}
81-
82-
await axios.put(uploadUrl, fileBuffer, {
83-
headers: {
84-
"Content-Type": "application/tar",
85-
},
86-
onUploadProgress: (progressEvent) => {
87-
logger.log(progressEvent);
88-
},
89-
});
90-
91-
WindowManager.mainWindow?.webContents.send(
92-
`on-upload-complete-${objectId}-${shop}`,
93-
true
94-
);
95-
96-
fs.rm(bundleLocation, (err) => {
97-
if (err) {
98-
logger.error("Failed to remove tar file", err);
99-
throw err;
100-
}
101-
});
102-
});
103-
});
10422
};
10523

10624
registerEvent("uploadSaveGame", uploadSaveGame);

src/main/events/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import "./library/remove-game";
3131
import "./library/remove-game-from-library";
3232
import "./library/select-game-wine-prefix";
3333
import "./library/reset-game-achievements";
34+
import "./library/toggle-automatic-cloud-sync";
3435
import "./misc/open-checkout";
3536
import "./misc/open-external";
3637
import "./misc/show-open-dialog";
3738
import "./misc/get-features";
3839
import "./misc/show-item-in-folder";
40+
import "./misc/get-badges";
3941
import "./torrenting/cancel-game-download";
4042
import "./torrenting/pause-game-download";
4143
import "./torrenting/resume-game-download";
@@ -58,6 +60,7 @@ import "./user/get-blocked-users";
5860
import "./user/block-user";
5961
import "./user/unblock-user";
6062
import "./user/get-user-friends";
63+
import "./user/get-auth";
6164
import "./user/get-user-stats";
6265
import "./user/report-user";
6366
import "./user/get-unlocked-achievements";
@@ -87,7 +90,6 @@ import "./themes/get-custom-theme-by-id";
8790
import "./themes/get-active-custom-theme";
8891
import "./themes/close-editor-window";
8992
import "./themes/toggle-custom-theme";
90-
import "./misc/get-badges";
9193
import { isPortableVersion } from "@main/helpers";
9294

9395
ipcMain.handle("ping", () => "pong");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { registerEvent } from "../register-event";
2+
import { levelKeys, gamesSublevel } from "@main/level";
3+
import type { GameShop } from "@types";
4+
5+
const toggleAutomaticCloudSync = async (
6+
_event: Electron.IpcMainInvokeEvent,
7+
shop: GameShop,
8+
objectId: string,
9+
automaticCloudSync: boolean
10+
) => {
11+
const gameKey = levelKeys.game(shop, objectId);
12+
13+
const game = await gamesSublevel.get(gameKey);
14+
15+
if (!game) return;
16+
17+
await gamesSublevel.put(gameKey, {
18+
...game,
19+
automaticCloudSync,
20+
});
21+
};
22+
23+
registerEvent("toggleAutomaticCloudSync", toggleAutomaticCloudSync);

src/main/events/user/get-auth.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { db, levelKeys } from "@main/level";
2+
import type { Auth } from "@types";
3+
4+
import { registerEvent } from "../register-event";
5+
6+
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
7+
db.get<string, Auth>(levelKeys.auth, {
8+
valueEncoding: "json",
9+
});
10+
11+
registerEvent("getAuth", getAuth);

src/main/main.ts

-2
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ export const loadState = async () => {
6262
game.uri !== null
6363
);
6464

65-
console.log("downloadsToSeed", downloadsToSeed);
66-
6765
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
6866

6967
startMainLoop();

src/main/services/cloud-sync.ts

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { levelKeys, gamesSublevel, db } from "@main/level";
2+
import { app } from "electron";
3+
import path from "node:path";
4+
import * as tar from "tar";
5+
import crypto from "node:crypto";
6+
import fs from "node:fs";
7+
import os from "node:os";
8+
import type { GameShop, User } from "@types";
9+
import { backupsPath } from "@main/constants";
10+
import { HydraApi } from "./hydra-api";
11+
import { normalizePath } from "@main/helpers";
12+
import { logger } from "./logger";
13+
import { WindowManager } from "./window-manager";
14+
import axios from "axios";
15+
import { Ludusavi } from "./ludusavi";
16+
import { isFuture, isToday } from "date-fns";
17+
import { SubscriptionRequiredError } from "@shared";
18+
19+
export class CloudSync {
20+
private static async bundleBackup(
21+
shop: GameShop,
22+
objectId: string,
23+
winePrefix: string | null
24+
) {
25+
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
26+
27+
// Remove existing backup
28+
if (fs.existsSync(backupPath)) {
29+
fs.rmSync(backupPath, { recursive: true });
30+
}
31+
32+
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
33+
34+
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
35+
36+
await tar.create(
37+
{
38+
gzip: false,
39+
file: tarLocation,
40+
cwd: backupPath,
41+
},
42+
["."]
43+
);
44+
45+
return tarLocation;
46+
}
47+
48+
public static async uploadSaveGame(
49+
objectId: string,
50+
shop: GameShop,
51+
downloadOptionTitle: string | null,
52+
label?: string
53+
) {
54+
const hasActiveSubscription = await db
55+
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
56+
.then((user) => {
57+
const expiresAt = user?.subscription?.expiresAt;
58+
return expiresAt && (isFuture(expiresAt) || isToday(expiresAt));
59+
});
60+
61+
if (!hasActiveSubscription) {
62+
throw new SubscriptionRequiredError();
63+
}
64+
65+
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
66+
67+
const bundleLocation = await this.bundleBackup(
68+
shop,
69+
objectId,
70+
game?.winePrefixPath ?? null
71+
);
72+
73+
const stat = await fs.promises.stat(bundleLocation);
74+
75+
const { uploadUrl } = await HydraApi.post<{
76+
id: string;
77+
uploadUrl: string;
78+
}>("/profile/games/artifacts", {
79+
artifactLengthInBytes: stat.size,
80+
shop,
81+
objectId,
82+
hostname: os.hostname(),
83+
homeDir: normalizePath(app.getPath("home")),
84+
downloadOptionTitle,
85+
platform: os.platform(),
86+
label,
87+
});
88+
89+
const fileBuffer = await fs.promises.readFile(bundleLocation);
90+
91+
await axios.put(uploadUrl, fileBuffer, {
92+
headers: {
93+
"Content-Type": "application/tar",
94+
},
95+
onUploadProgress: (progressEvent) => {
96+
logger.log(progressEvent);
97+
},
98+
});
99+
100+
WindowManager.mainWindow?.webContents.send(
101+
`on-upload-complete-${objectId}-${shop}`,
102+
true
103+
);
104+
105+
fs.rm(bundleLocation, (err) => {
106+
if (err) {
107+
logger.error("Failed to remove tar file", err);
108+
throw err;
109+
}
110+
});
111+
}
112+
}

src/main/services/download/torbox.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
TorBoxAddTorrentRequest,
77
TorBoxRequestLinkRequest,
88
} from "@types";
9+
import { appVersion } from "@main/constants";
910

1011
export class TorBoxClient {
1112
private static instance: AxiosInstance;
@@ -18,6 +19,7 @@ export class TorBoxClient {
1819
baseURL: this.baseURL,
1920
headers: {
2021
Authorization: `Bearer ${apiToken}`,
22+
"User-Agent": `Hydra/${appVersion}`,
2123
},
2224
});
2325
}

src/main/services/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from "./process-watcher";
77
export * from "./main-loop";
88
export * from "./hydra-api";
99
export * from "./ludusavi";
10+
export * from "./cloud-sync";

0 commit comments

Comments
 (0)