mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 01:03:57 +00:00
ci: updating build to support ws url
This commit is contained in:
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
45
src/main/services/system-path.ts
Normal file
45
src/main/services/system-path.ts
Normal 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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user