ci: updating build to support ws url

This commit is contained in:
Chubby Granny Chaser
2025-05-09 20:52:03 +01:00
parent aa18b57ada
commit 6c44cc0cc4
83 changed files with 1810 additions and 3040 deletions

View File

@@ -1,23 +1,32 @@
import { app } from "electron";
import path from "node:path";
import { SystemPath } from "./services/system-path";
export const LUDUSAVI_MANIFEST_URL = "https://cdn.losbroxas.org/manifest.yaml";
export const defaultDownloadsPath = app.getPath("downloads");
export const defaultDownloadsPath = SystemPath.getPath("downloads");
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
export const windowsStartMenuPath = path.join(
SystemPath.getPath("appData"),
"Microsoft",
"Windows",
"Start Menu",
"Programs"
);
export const levelDatabasePath = path.join(
app.getPath("userData"),
SystemPath.getPath("userData"),
`hydra-db${isStaging ? "-staging" : ""}`
);
export const commonRedistPath = path.join(
app.getPath("userData"),
SystemPath.getPath("userData"),
"CommonRedist"
);
export const logsPath = path.join(app.getPath("userData"), "logs");
export const logsPath = path.join(SystemPath.getPath("userData"), "logs");
export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds")
@@ -27,7 +36,7 @@ export const achievementSoundPath = app.isPackaged
? path.join(process.resourcesPath, "achievement.wav")
: path.join(__dirname, "..", "..", "resources", "achievement.wav");
export const backupsPath = path.join(app.getPath("userData"), "Backups");
export const backupsPath = path.join(SystemPath.getPath("userData"), "Backups");
export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");

View File

@@ -6,7 +6,7 @@ import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
valueEncoding: "utf8",
})
.then((language) => language || "en");

View File

@@ -4,13 +4,13 @@ import * as tar from "tar";
import { registerEvent } from "../register-event";
import axios from "axios";
import os from "node:os";
import { app } from "electron";
import path from "node:path";
import { backupsPath } from "@main/constants";
import type { GameShop } from "@types";
import YAML from "yaml";
import { normalizePath } from "@main/helpers";
import { SystemPath } from "@main/services/system-path";
export interface LudusaviBackup {
files: {
@@ -35,7 +35,7 @@ const replaceLudusaviBackupWithCurrentUser = (
drives: Record<string, string>;
};
const currentHomeDir = normalizePath(app.getPath("home"));
const currentHomeDir = normalizePath(SystemPath.getPath("home"));
/* Renaming logic */
if (os.platform() === "win32") {
@@ -84,7 +84,7 @@ const downloadGameArtifact = async (
homeDir: string;
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
const zipLocation = path.join(app.getPath("userData"), objectKey);
const zipLocation = path.join(SystemPath.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
if (fs.existsSync(backupPath)) {

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import type { Game, GameShop } from "@types";
import type { GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
@@ -15,15 +15,14 @@ const addGameToLibrary = async (
title: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
let game = await gamesSublevel.get(gameKey);
if (game) {
await downloadsSublevel.del(gameKey);
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
game.isDeleted = false;
await gamesSublevel.put(gameKey, game);
} else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
@@ -33,7 +32,7 @@ const addGameToLibrary = async (
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
const game: Game = {
game = {
title,
iconUrl,
objectId,
@@ -44,12 +43,12 @@ const addGameToLibrary = async (
lastTimePlayed: null,
};
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
await createGame(game).catch(() => {});
updateLocalUnlockedAchievements(game);
await gamesSublevel.put(gameKey, game);
}
await createGame(game).catch(() => {});
updateLocalUnlockedAchievements(game);
};
registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -3,13 +3,16 @@ import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path";
import { app } from "electron";
import { removeSymbolsFromName } from "@shared";
import { GameShop } from "@types";
import { GameShop, ShortcutLocation } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import { SystemPath } from "@main/services/system-path";
import { windowsStartMenuPath } from "@main/constants";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
objectId: string,
location: ShortcutLocation
): Promise<boolean> => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
@@ -24,7 +27,10 @@ const createGameShortcut = async (
const options = {
filePath,
name: removeSymbolsFromName(game.title),
outputPath: app.getPath("desktop"),
outputPath:
location === "desktop"
? SystemPath.getPath("desktop")
: windowsStartMenuPath,
};
return createDesktopShortcut({

View File

@@ -6,7 +6,7 @@ import { db, levelKeys } from "@main/level";
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
valueEncoding: "utf8",
})
.then((language) => language || "en");

View File

@@ -1,4 +1,3 @@
import { MAIN_LOOP_INTERVAL } from "@main/constants";
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { publishNewFriendRequestNotification } from "@main/services/notifications";
@@ -10,14 +9,12 @@ interface SyncState {
tick: number;
}
const ticksToUpdate = (2 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 2 minutes
const syncState: SyncState = {
friendRequestCount: null,
tick: 0,
};
const syncFriendRequests = async () => {
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
if (
@@ -44,16 +41,4 @@ const syncFriendRequests = async () => {
});
};
const syncFriendRequestsEvent = async (_event: Electron.IpcMainInvokeEvent) => {
return syncFriendRequests();
};
export const watchFriendRequests = async () => {
if (syncState.tick % ticksToUpdate === 0) {
await syncFriendRequests();
}
syncState.tick++;
};
registerEvent("syncFriendRequests", syncFriendRequestsEvent);
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -40,7 +40,7 @@ const startGameDownload = async (
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game?.isDeleted) {
if (game) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,

View File

@@ -1,19 +1,8 @@
import { registerEvent } from "../register-event";
import AutoLaunch from "auto-launch";
import { app } from "electron";
import path from "path";
import fs from "node:fs";
import { logger } from "@main/services";
const windowsStartupPath = path.join(
app.getPath("appData"),
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup"
);
const autoLaunch = async (
_event: Electron.IpcMainInvokeEvent,
autoLaunchProps: { enabled: boolean; minimized: boolean }
@@ -30,10 +19,6 @@ const autoLaunch = async (
logger.error(err);
});
} else {
if (process.platform == "win32") {
fs.rm(path.join(windowsStartupPath, "Hydra.vbs"), () => {});
}
appLauncher.disable().catch((err) => {
logger.error(err);
});

View File

@@ -16,7 +16,7 @@ const updateUserPreferences = async (
if (preferences.language) {
await db.put<string, string>(levelKeys.language, preferences.language, {
valueEncoding: "utf-8",
valueEncoding: "utf8",
});
i18next.changeLanguage(preferences.language);

View File

@@ -0,0 +1,242 @@
// @generated by protobuf-ts 2.10.0
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
import type { IBinaryWriter } from "@protobuf-ts/runtime";
import { WireType } from "@protobuf-ts/runtime";
import type { BinaryReadOptions } from "@protobuf-ts/runtime";
import type { IBinaryReader } from "@protobuf-ts/runtime";
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
/**
* @generated from protobuf message FriendRequest
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1;
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2;
*/
senderId?: string;
}
/**
* @generated from protobuf message UpdateGamePlaytime
*/
export interface UpdateGamePlaytime {
/**
* @generated from protobuf field: int64 playtime_delta_in_seconds = 1;
*/
playtimeDeltaInSeconds: bigint;
/**
* @generated from protobuf field: string last_time_played = 2;
*/
lastTimePlayed: string;
/**
* @generated from protobuf field: string game_id = 3;
*/
gameId: string;
}
/**
* @generated from protobuf message Envelope
*/
export interface Envelope {
/**
* @generated from protobuf oneof: payload
*/
payload: {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1;
*/
friendRequest: FriendRequest;
} | {
oneofKind: "updateGamePlaytime";
/**
* @generated from protobuf field: UpdateGamePlaytime update_game_playtime = 2;
*/
updateGamePlaytime: UpdateGamePlaytime;
} | {
oneofKind: undefined;
};
}
// @generated message type with reflection information, may provide speed optimized methods
class FriendRequest$Type extends MessageType<FriendRequest> {
constructor() {
super("FriendRequest", [
{ no: 1, name: "friend_request_count", kind: "scalar", T: 5 /*ScalarType.INT32*/ },
{ no: 2, name: "sender_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<FriendRequest>): FriendRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.friendRequestCount = 0;
if (value !== undefined)
reflectionMergePartial<FriendRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: FriendRequest): FriendRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 friend_request_count */ 1:
message.friendRequestCount = reader.int32();
break;
case /* optional string sender_id */ 2:
message.senderId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: FriendRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* int32 friend_request_count = 1; */
if (message.friendRequestCount !== 0)
writer.tag(1, WireType.Varint).int32(message.friendRequestCount);
/* optional string sender_id = 2; */
if (message.senderId !== undefined)
writer.tag(2, WireType.LengthDelimited).string(message.senderId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message FriendRequest
*/
export const FriendRequest = new FriendRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class UpdateGamePlaytime$Type extends MessageType<UpdateGamePlaytime> {
constructor() {
super("UpdateGamePlaytime", [
{ no: 1, name: "playtime_delta_in_seconds", kind: "scalar", T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ },
{ no: 2, name: "last_time_played", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "game_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<UpdateGamePlaytime>): UpdateGamePlaytime {
const message = globalThis.Object.create((this.messagePrototype!));
message.playtimeDeltaInSeconds = 0n;
message.lastTimePlayed = "";
message.gameId = "";
if (value !== undefined)
reflectionMergePartial<UpdateGamePlaytime>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: UpdateGamePlaytime): UpdateGamePlaytime {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int64 playtime_delta_in_seconds */ 1:
message.playtimeDeltaInSeconds = reader.int64().toBigInt();
break;
case /* string last_time_played */ 2:
message.lastTimePlayed = reader.string();
break;
case /* string game_id */ 3:
message.gameId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: UpdateGamePlaytime, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* int64 playtime_delta_in_seconds = 1; */
if (message.playtimeDeltaInSeconds !== 0n)
writer.tag(1, WireType.Varint).int64(message.playtimeDeltaInSeconds);
/* string last_time_played = 2; */
if (message.lastTimePlayed !== "")
writer.tag(2, WireType.LengthDelimited).string(message.lastTimePlayed);
/* string game_id = 3; */
if (message.gameId !== "")
writer.tag(3, WireType.LengthDelimited).string(message.gameId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message UpdateGamePlaytime
*/
export const UpdateGamePlaytime = new UpdateGamePlaytime$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
{ no: 1, name: "friend_request", kind: "message", oneof: "payload", T: () => FriendRequest },
{ no: 2, name: "update_game_playtime", kind: "message", oneof: "payload", T: () => UpdateGamePlaytime }
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
const message = globalThis.Object.create((this.messagePrototype!));
message.payload = { oneofKind: undefined };
if (value !== undefined)
reflectionMergePartial<Envelope>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: Envelope): Envelope {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* FriendRequest friend_request */ 1:
message.payload = {
oneofKind: "friendRequest",
friendRequest: FriendRequest.internalBinaryRead(reader, reader.uint32(), options, (message.payload as any).friendRequest)
};
break;
case /* UpdateGamePlaytime update_game_playtime */ 2:
message.payload = {
oneofKind: "updateGamePlaytime",
updateGamePlaytime: UpdateGamePlaytime.internalBinaryRead(reader, reader.uint32(), options, (message.payload as any).updateGamePlaytime)
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: Envelope, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* FriendRequest friend_request = 1; */
if (message.payload.oneofKind === "friendRequest")
FriendRequest.internalBinaryWrite(message.payload.friendRequest, writer.tag(1, WireType.LengthDelimited).fork(), options).join();
/* UpdateGamePlaytime update_game_playtime = 2; */
if (message.payload.oneofKind === "updateGamePlaytime")
UpdateGamePlaytime.internalBinaryWrite(message.payload.updateGamePlaytime, writer.tag(2, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message Envelope
*/
export const Envelope = new Envelope$Type();

View File

@@ -59,9 +59,11 @@ app.whenReady().then(async () => {
await loadState();
const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
});
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.catch(() => "en");
if (language) i18n.changeLanguage(language);

View File

@@ -14,4 +14,5 @@ export const levelKeys = {
userPreferences: "userPreferences",
language: "language",
screenState: "screenState",
rpcPassword: "rpcPassword",
};

View File

@@ -10,8 +10,11 @@ import type { UserPreferences } from "@types";
import { TorBoxClient } from "./services/download/torbox";
import { CommonRedistManager } from "./services/common-redist-manager";
import { WSManager } from "./services/ws-manager";
import { SystemPath } from "./services/system-path";
export const loadState = async () => {
SystemPath.checkIfPathsAreAvailable();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{

View File

@@ -1,26 +1,26 @@
import path from "node:path";
import fs from "node:fs";
import { app } from "electron";
import type { Game, AchievementFile } from "@types";
import { Cracker } from "@shared";
import { achievementsLogger } from "../logger";
import { SystemPath } from "../system-path";
const getAppDataPath = () => {
if (process.platform === "win32") {
return app.getPath("appData");
return SystemPath.getPath("appData");
}
const user = app.getPath("home").split("/").pop();
const user = SystemPath.getPath("home").split("/").pop();
return path.join("drive_c", "users", user || "", "AppData", "Roaming");
};
const getDocumentsPath = () => {
if (process.platform === "win32") {
return app.getPath("documents");
return SystemPath.getPath("documents");
}
const user = app.getPath("home").split("/").pop();
const user = SystemPath.getPath("home").split("/").pop();
return path.join("drive_c", "users", user || "", "Documents");
};
@@ -38,7 +38,7 @@ const getLocalAppDataPath = () => {
return path.join(appData, "..", "Local");
}
const user = app.getPath("home").split("/").pop();
const user = SystemPath.getPath("home").split("/").pop();
return path.join("drive_c", "users", user || "", "AppData", "Local");
};

View File

@@ -25,7 +25,7 @@ export const getGameAchievementData = async (
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
valueEncoding: "utf8",
})
.then((language) => language || "en");

View File

@@ -208,6 +208,19 @@ const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
if (Array.isArray(unlockedAchievements)) {
for (const achievement of unlockedAchievements) {
if (achievement?.earned) {
newUnlockedAchievements.push({
name: achievement.name,
unlockTime: achievement.earned_time * 1000,
});
}
}
return newUnlockedAchievements;
}
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];

View File

@@ -1,5 +1,4 @@
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";
@@ -15,6 +14,7 @@ import axios from "axios";
import { Ludusavi } from "./ludusavi";
import { formatDate, SubscriptionRequiredError } from "@shared";
import i18next, { t } from "i18next";
import { SystemPath } from "./system-path";
export class CloudSync {
public static getBackupLabel(automatic: boolean) {
@@ -102,7 +102,7 @@ export class CloudSync {
shop,
objectId,
hostname: os.hostname(),
homeDir: normalizePath(app.getPath("home")),
homeDir: normalizePath(SystemPath.getPath("home")),
downloadOptionTitle,
platform: os.platform(),
label,

View File

@@ -4,24 +4,21 @@ import fs from "node:fs";
import cp from "node:child_process";
import path from "node:path";
import { logger } from "./logger";
import { app } from "electron";
import { WindowManager } from "./window-manager";
import { SystemPath } from "./system-path";
export class CommonRedistManager {
private static readonly redistributables = [
"dotNetFx40_Full_setup.exe",
"dxwebsetup.exe",
"directx_Jun2010_redist.exe",
"oalinst.exe",
"install.bat",
"vcredist_2015-2019_x64.exe",
"vcredist_2015-2019_x86.exe",
"vcredist_x64.exe",
"vcredist_x86.exe",
"xnafx40_redist.msi",
"VisualCppRedist_AIO_x86_x64.exe",
];
private static readonly installationTimeout = 1000 * 60 * 5; // 5 minutes
private static readonly installationLog = path.join(
app.getPath("temp"),
SystemPath.getPath("temp"),
"common_redist_install.log"
);
@@ -47,6 +44,8 @@ export class CommonRedistManager {
fs.readFile(this.installationLog, "utf-8", (err, data) => {
if (err) return logger.error("Error reading log file:", err);
logger.log("Redist log file updated:", data);
const tail = data.split("\n").at(-2)?.trim();
if (tail?.includes(installationCompleteMessage)) {
@@ -92,7 +91,7 @@ export class CommonRedistManager {
for (const redist of this.redistributables) {
const filePath = path.join(commonRedistPath, redist);
if (fs.existsSync(filePath)) {
if (fs.existsSync(filePath) && redist !== "install.bat") {
continue;
}

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSManager } from "./ws-manager";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -101,6 +102,8 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
WSManager.close();
WSManager.connect();
}
}

View File

@@ -5,7 +5,7 @@ import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
return HydraApi.post(`/profile/games`, {
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
}).then((response) => {

View File

@@ -8,9 +8,13 @@ import YAML from "yaml";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
import { LUDUSAVI_MANIFEST_URL } from "@main/constants";
import { SystemPath } from "./system-path";
export class Ludusavi {
private static ludusaviPath = path.join(app.getPath("appData"), "ludusavi");
private static ludusaviPath = path.join(
SystemPath.getPath("appData"),
"ludusavi"
);
private static ludusaviConfigPath = path.join(
this.ludusaviPath,
"config.yaml"

View File

@@ -3,7 +3,6 @@ import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { UpdateManager } from "./update-manager";
import { watchFriendRequests } from "@main/events/profile/sync-friend-requests";
import { MAIN_LOOP_INTERVAL } from "@main/constants";
export const startMainLoop = async () => {
@@ -11,7 +10,6 @@ export const startMainLoop = async () => {
while (true) {
await Promise.allSettled([
watchProcesses(),
watchFriendRequests(),
DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(),

View File

@@ -1,4 +1,4 @@
import { Notification, app } from "electron";
import { Notification } from "electron";
import { t } from "i18next";
import trayIcon from "@resources/tray-icon.png?asset";
import fs from "node:fs";
@@ -13,13 +13,14 @@ import { WindowManager } from "../window-manager";
import type { Game, UserPreferences } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
async function downloadImage(url: string | null) {
if (!url) return undefined;
if (!url.startsWith("http")) return undefined;
const fileName = url.split("/").pop()!;
const outputPath = path.join(app.getPath("temp"), fileName);
const outputPath = path.join(SystemPath.getPath("temp"), fileName);
const writer = fs.createWriteStream(outputPath);
const response = await axios.get(url, {
@@ -80,7 +81,9 @@ export const publishNotificationUpdateReadyToInstall = async (
.show();
};
export const publishNewFriendRequestNotification = async () => {
export const publishNewFriendRequestNotification = async (
senderProfileImageUrl?: string
) => {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -97,7 +100,9 @@ export const publishNewFriendRequestNotification = async () => {
body: t("new_friend_request_description", {
ns: "notifications",
}),
icon: trayIcon,
icon: senderProfileImageUrl
? await downloadImage(senderProfileImageUrl)
: trayIcon,
}).show();
};

View File

@@ -7,7 +7,8 @@ import crypto from "node:crypto";
import { pythonRpcLogger } from "./logger";
import { Readable } from "node:stream";
import { app, dialog } from "electron";
import { app, dialog, safeStorage } from "electron";
import { db, levelKeys } from "@main/level";
interface GamePayload {
game_id: string;
@@ -30,17 +31,12 @@ const rustBinaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881";
public static readonly RPC_PORT = "8084";
private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
private static pythonProcess: cp.ChildProcess | null = null;
public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`,
headers: {
"x-hydra-rpc-password": this.RPC_PASSWORD,
},
});
private static pythonProcess: cp.ChildProcess | null = null;
private static logStderr(readable: Readable | null) {
if (!readable) return;
@@ -48,14 +44,37 @@ export class PythonRPC {
readable.on("data", pythonRpcLogger.log);
}
public static spawn(
private static async getRPCPassword() {
const existingPassword = await db.get(levelKeys.rpcPassword, {
valueEncoding: "utf8",
});
if (existingPassword)
return safeStorage.decryptString(Buffer.from(existingPassword, "hex"));
const newPassword = crypto.randomBytes(32).toString("hex");
await db.put(
levelKeys.rpcPassword,
safeStorage.encryptString(newPassword).toString("hex"),
{
valueEncoding: "utf8",
}
);
return newPassword;
}
public static async spawn(
initialDownload?: GamePayload,
initialSeeding?: GamePayload[]
) {
const rpcPassword = await this.getRPCPassword();
const commonArgs = [
this.BITTORRENT_PORT,
this.RPC_PORT,
this.RPC_PASSWORD,
rpcPassword,
initialDownload ? JSON.stringify(initialDownload) : "",
initialSeeding ? JSON.stringify(initialSeeding) : "",
app.isPackaged
@@ -116,6 +135,8 @@ export class PythonRPC {
this.pythonProcess = childProcess;
}
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
}
public static kill() {

View File

@@ -0,0 +1,45 @@
import { app, dialog } from "electron";
import { logger } from "./logger";
export class SystemPath {
static readonly paths = {
userData: "userData",
downloads: "downloads",
documents: "documents",
desktop: "desktop",
home: "home",
appData: "appData",
temp: "temp",
};
static checkIfPathsAreAvailable() {
const paths = Object.keys(SystemPath.paths) as Array<
keyof typeof SystemPath.paths
>;
paths.forEach((pathName) => {
try {
app.getPath(pathName);
} catch (error) {
logger.error(`Error getting path ${pathName}`);
if (error instanceof Error) {
logger.error(error.message, error.stack);
}
dialog.showErrorBox(
`Hydra was not able to find path for '${pathName}' system folder`,
`Some functionalities may not work as expected.\nPlease check your system settings.`
);
}
});
}
static getPath(pathName: keyof typeof SystemPath.paths): string {
try {
return app.getPath(pathName);
} catch (error) {
logger.error(`Error getting path: ${error}`);
return "";
}
}
}

View File

@@ -16,7 +16,7 @@ import trayIcon from "@resources/tray-icon.png?asset";
import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { slice, sortBy } from "lodash-es";
import { orderBy, slice } from "lodash-es";
import type { ScreenState, UserPreferences } from "@types";
import { AuthPage } from "@shared";
import { isStaging } from "@main/constants";
@@ -370,14 +370,14 @@ export class WindowManager {
!game.isDeleted && game.executablePath && game.lastTimePlayed
);
const sortedGames = sortBy(filteredGames, "lastTimePlayed", "DESC");
const sortedGames = orderBy(filteredGames, "lastTimePlayed", "desc");
return slice(sortedGames, 5);
return slice(sortedGames, 0, 6);
});
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({
label: title.length > 15 ? `${title.slice(0, 15)}` : title,
label: title.length > 18 ? `${title.slice(0, 18)}` : title,
type: "normal",
click: async () => {
if (!executablePath) return;
@@ -418,7 +418,10 @@ export class WindowManager {
},
]);
tray.setContextMenu(contextMenu);
if (process.platform === "linux") {
tray.setContextMenu(contextMenu);
}
return contextMenu;
};

View File

@@ -1,14 +1,19 @@
import { WebSocket } from "ws";
import { HydraApi } from "./hydra-api";
import { Envelope } from "@main/generated/envelope";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
export class WSManager {
private static ws: WebSocket;
private static ws: WebSocket | null = null;
private static reconnectInterval = 1000;
private static maxReconnectInterval = 30000;
private static reconnectAttempts = 0;
private static reconnecting = false;
static async connect() {
const { token } = await HydraApi.post<{ token: string }>("/auth/ws");
console.log("WS TOKEN", token);
this.ws = new WebSocket(import.meta.env.MAIN_VITE_WS_URL, {
headers: {
Authorization: `Bearer ${token}`,
@@ -16,15 +21,55 @@ export class WSManager {
});
this.ws.on("open", () => {
console.log("open");
});
this.ws.on("error", (error) => {
console.error(error);
logger.info("WS connected");
this.reconnectInterval = 1000;
this.reconnecting = false;
});
this.ws.on("message", (message) => {
console.log(message);
const envelope = Envelope.fromBinary(
new Uint8Array(Buffer.from(message.toString()))
);
if (envelope.payload.oneofKind === "friendRequest") {
WindowManager.mainWindow?.webContents.send("on-sync-friend-requests", {
friendRequestCount: envelope.payload.friendRequest.friendRequestCount,
});
}
});
this.ws.on("close", () => {
logger.warn("WS closed. Attempting reconnect...");
this.tryReconnect();
});
this.ws.on("error", (err) => {
logger.error("WS error:", err);
this.tryReconnect();
});
}
private static async tryReconnect() {
if (this.reconnecting) return;
this.reconnecting = true;
this.reconnectAttempts++;
const waitTime = Math.min(
this.reconnectInterval * 2 ** this.reconnectAttempts,
this.maxReconnectInterval
);
logger.info(`Reconnecting in ${waitTime / 1000}s...`);
setTimeout(() => {
this.connect();
}, waitTime);
}
public static async close() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}