mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 09:13:57 +00:00
Merge branch 'main' into feature/seed-completed-downloads
This commit is contained in:
@@ -44,6 +44,9 @@ export class UserPreferences {
|
||||
@Column("boolean", { default: true })
|
||||
seedAfterDownloadComplete: boolean;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
showHiddenAchievementsDescription: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const getHowLongToBeat = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
objectId: objectId.toString(),
|
||||
});
|
||||
|
||||
return HydraApi.get(`/games/how-long-to-beat?${params.toString()}`, null, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appVersion, defaultDownloadsPath } from "@main/constants";
|
||||
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import "./catalogue/get-catalogue";
|
||||
@@ -74,5 +74,6 @@ import "./misc/show-item-in-folder";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => appVersion);
|
||||
ipcMain.handle("isStaging", () => isStaging);
|
||||
ipcMain.handle("isPortableVersion", () => isPortableVersion());
|
||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
|
||||
|
||||
@@ -25,7 +25,11 @@ const closeGame = async (
|
||||
if (!game) return;
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
return runningProcess.exe === game.executablePath;
|
||||
if (process.platform === "linux") {
|
||||
return runningProcess.name === game.executablePath?.split("/").at(-1);
|
||||
} else {
|
||||
return runningProcess.exe === game.executablePath;
|
||||
}
|
||||
});
|
||||
|
||||
if (gameProcess) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import { registerEvent } from "../register-event";
|
||||
const selectGameWinePrefix = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: number,
|
||||
winePrefixPath: string
|
||||
winePrefixPath: string | null
|
||||
) => {
|
||||
return gameRepository.update({ id }, { winePrefixPath });
|
||||
return gameRepository.update({ id }, { winePrefixPath: winePrefixPath });
|
||||
};
|
||||
|
||||
registerEvent("selectGameWinePrefix", selectGameWinePrefix);
|
||||
|
||||
@@ -6,14 +6,18 @@ import { parseExecutablePath } from "../helpers/parse-executable-path";
|
||||
const updateExecutablePath = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: number,
|
||||
executablePath: string
|
||||
executablePath: string | null
|
||||
) => {
|
||||
const parsedPath = executablePath
|
||||
? parseExecutablePath(executablePath)
|
||||
: null;
|
||||
|
||||
return gameRepository.update(
|
||||
{
|
||||
id,
|
||||
},
|
||||
{
|
||||
executablePath: parseExecutablePath(executablePath),
|
||||
executablePath: parsedPath,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { StartGameDownloadPayload } from "@types";
|
||||
import { DownloadManager, HydraApi, logger } from "@main/services";
|
||||
import { DownloadManager, HydraApi } from "@main/services";
|
||||
|
||||
import { Not } from "typeorm";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
@@ -76,24 +76,23 @@ const startGameDownload = async (
|
||||
},
|
||||
});
|
||||
|
||||
createGame(updatedGame!).catch(() => {});
|
||||
|
||||
HydraApi.post(
|
||||
"/games/download",
|
||||
{
|
||||
objectId: updatedGame!.objectID,
|
||||
shop: updatedGame!.shop,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
).catch((err) => {
|
||||
logger.error("Failed to create game download", err);
|
||||
});
|
||||
|
||||
await DownloadManager.cancelDownload(updatedGame!.id);
|
||||
await DownloadManager.startDownload(updatedGame!);
|
||||
|
||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||
|
||||
await Promise.all([
|
||||
createGame(updatedGame!).catch(() => {}),
|
||||
HydraApi.post(
|
||||
"/games/download",
|
||||
{
|
||||
objectId: updatedGame!.objectID,
|
||||
shop: updatedGame!.shop,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
).catch(() => {}),
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { GameShop, UnlockedAchievement, UserAchievement } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameAchievementRepository } from "@main/repository";
|
||||
import {
|
||||
gameAchievementRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
|
||||
|
||||
export const getUnlockedAchievements = async (
|
||||
@@ -12,10 +15,17 @@ export const getUnlockedAchievements = async (
|
||||
where: { objectId, shop },
|
||||
});
|
||||
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const showHiddenAchievementsDescription =
|
||||
userPreferences?.showHiddenAchievementsDescription || false;
|
||||
|
||||
const achievementsData = await getGameAchievementData(
|
||||
objectId,
|
||||
shop,
|
||||
useCachedData
|
||||
useCachedData ? cachedAchievements : null
|
||||
);
|
||||
|
||||
const unlockedAchievements = JSON.parse(
|
||||
@@ -50,6 +60,10 @@ export const getUnlockedAchievements = async (
|
||||
unlocked: false,
|
||||
unlockTime: null,
|
||||
icongray: icongray,
|
||||
description:
|
||||
!achievementData.hidden || showHiddenAchievementsDescription
|
||||
? achievementData.description
|
||||
: undefined,
|
||||
} as UserAchievement;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_m
|
||||
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
|
||||
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
|
||||
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
|
||||
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
|
||||
|
||||
export type HydraMigration = Knex.Migration & { name: string };
|
||||
|
||||
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||
@@ -34,6 +36,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||
AddDisableNsfwAlertColumn,
|
||||
AddShouldSeedColumn,
|
||||
AddSeedAfterDownloadColumn,
|
||||
AddHiddenAchievementDescriptionColumn,
|
||||
]);
|
||||
}
|
||||
getMigrationName(migration: HydraMigration): string {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { HydraMigration } from "@main/knex-client";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export const AddHiddenAchievementDescriptionColumn: HydraMigration = {
|
||||
name: "AddHiddenAchievementDescriptionColumn",
|
||||
up: (knex: Knex) => {
|
||||
return knex.schema.alterTable("user_preferences", (table) => {
|
||||
return table
|
||||
.boolean("showHiddenAchievementsDescription")
|
||||
.notNullable()
|
||||
.defaultTo(0);
|
||||
});
|
||||
},
|
||||
|
||||
down: async (knex: Knex) => {
|
||||
return knex.schema.alterTable("user_preferences", (table) => {
|
||||
return table.dropColumn("showHiddenAchievementsDescription");
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getSteamGameClientIcon, logger } from "@main/services";
|
||||
import { chunk } from "lodash-es";
|
||||
import { seedsPath } from "@main/constants";
|
||||
|
||||
import type { SteamGame } from "@types";
|
||||
|
||||
const steamGamesPath = path.join(seedsPath, "steam-games.json");
|
||||
|
||||
const steamGames = JSON.parse(
|
||||
fs.readFileSync(steamGamesPath, "utf-8")
|
||||
) as SteamGame[];
|
||||
|
||||
const chunks = chunk(steamGames, 1500);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(
|
||||
chunk.map(async (steamGame) => {
|
||||
if (steamGame.clientIcon) return;
|
||||
|
||||
const index = steamGames.findIndex((game) => game.id === steamGame.id);
|
||||
|
||||
try {
|
||||
const clientIcon = await getSteamGameClientIcon(String(steamGame.id));
|
||||
|
||||
steamGames[index].clientIcon = clientIcon;
|
||||
|
||||
logger.log("info", `Set ${steamGame.name} client icon`);
|
||||
} catch (err) {
|
||||
steamGames[index].clientIcon = null;
|
||||
logger.log("info", `Could not set icon for ${steamGame.name}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
fs.writeFileSync(steamGamesPath, JSON.stringify(steamGames));
|
||||
logger.log("info", "Updated steam games");
|
||||
}
|
||||
@@ -236,24 +236,28 @@ export class AchievementWatcherManager {
|
||||
};
|
||||
|
||||
public static preSearchAchievements = async () => {
|
||||
const newAchievementsCount =
|
||||
process.platform === "win32"
|
||||
? await this.preSearchAchievementsWindows()
|
||||
: await this.preSearchAchievementsWithWine();
|
||||
try {
|
||||
const newAchievementsCount =
|
||||
process.platform === "win32"
|
||||
? await this.preSearchAchievementsWindows()
|
||||
: await this.preSearchAchievementsWithWine();
|
||||
|
||||
const totalNewGamesWithAchievements = newAchievementsCount.filter(
|
||||
(achievements) => achievements
|
||||
).length;
|
||||
const totalNewAchievements = newAchievementsCount.reduce(
|
||||
(acc, val) => acc + val,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalNewAchievements > 0) {
|
||||
publishCombinedNewAchievementNotification(
|
||||
totalNewAchievements,
|
||||
totalNewGamesWithAchievements
|
||||
const totalNewGamesWithAchievements = newAchievementsCount.filter(
|
||||
(achievements) => achievements
|
||||
).length;
|
||||
const totalNewAchievements = newAchievementsCount.reduce(
|
||||
(acc, val) => acc + val,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalNewAchievements > 0) {
|
||||
publishCombinedNewAchievementNotification(
|
||||
totalNewAchievements,
|
||||
totalNewGamesWithAchievements
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
achievementsLogger.error("Error on preSearchAchievements", err);
|
||||
}
|
||||
|
||||
this.hasFinishedMergingWithRemote = true;
|
||||
|
||||
@@ -6,20 +6,15 @@ import { HydraApi } from "../hydra-api";
|
||||
import type { AchievementData, GameShop } from "@types";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { logger } from "../logger";
|
||||
import { GameAchievement } from "@main/entity";
|
||||
|
||||
export const getGameAchievementData = async (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
useCachedData: boolean
|
||||
cachedAchievements: GameAchievement | null
|
||||
) => {
|
||||
if (useCachedData) {
|
||||
const cachedAchievements = await gameAchievementRepository.findOne({
|
||||
where: { objectId, shop },
|
||||
});
|
||||
|
||||
if (cachedAchievements && cachedAchievements.achievements) {
|
||||
return JSON.parse(cachedAchievements.achievements) as AchievementData[];
|
||||
}
|
||||
if (cachedAchievements && cachedAchievements.achievements) {
|
||||
return JSON.parse(cachedAchievements.achievements) as AchievementData[];
|
||||
}
|
||||
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
|
||||
@@ -9,144 +9,134 @@ export const parseAchievementFile = (
|
||||
): UnlockedAchievement[] => {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
if (type == Cracker.codex) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
try {
|
||||
if (type == Cracker.codex) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rune) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.onlineFix) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processOnlineFix(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.goldberg) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.userstats) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processUserStats(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rld) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processRld(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.skidrow) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processSkidrow(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker._3dm) {
|
||||
const parsed = iniParse(filePath);
|
||||
return process3DM(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.flt) {
|
||||
const achievements = readdirSync(filePath);
|
||||
|
||||
return achievements.map((achievement) => {
|
||||
return {
|
||||
name: achievement,
|
||||
unlockTime: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (type === Cracker.creamAPI) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processCreamAPI(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.empress) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.razor1911) {
|
||||
return processRazor1911(filePath);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
`Unprocessed ${type} achievements found on ${filePath}`
|
||||
);
|
||||
return [];
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${type} - ${filePath}`, err);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type == Cracker.rune) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.onlineFix) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processOnlineFix(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.goldberg) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.userstats) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processUserStats(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rld) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processRld(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.skidrow) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processSkidrow(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker._3dm) {
|
||||
const parsed = iniParse(filePath);
|
||||
return process3DM(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.flt) {
|
||||
const achievements = readdirSync(filePath);
|
||||
|
||||
return achievements.map((achievement) => {
|
||||
return {
|
||||
name: achievement,
|
||||
unlockTime: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (type === Cracker.creamAPI) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processCreamAPI(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.empress) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.razor1911) {
|
||||
return processRazor1911(filePath);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
`Unprocessed ${type} achievements found on ${filePath}`
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
const iniParse = (filePath: string) => {
|
||||
try {
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
|
||||
const lines =
|
||||
fileContent.charCodeAt(0) === 0xfeff
|
||||
? fileContent.slice(1).split(/[\r\n]+/)
|
||||
: fileContent.split(/[\r\n]+/);
|
||||
const lines =
|
||||
fileContent.charCodeAt(0) === 0xfeff
|
||||
? fileContent.slice(1).split(/[\r\n]+/)
|
||||
: fileContent.split(/[\r\n]+/);
|
||||
|
||||
let objectName = "";
|
||||
const object: Record<string, Record<string, string | number>> = {};
|
||||
let objectName = "";
|
||||
const object: Record<string, Record<string, string | number>> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("###") || !line.length) continue;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("###") || !line.length) continue;
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
objectName = line.slice(1, -1);
|
||||
object[objectName] = {};
|
||||
} else {
|
||||
const [name, ...value] = line.split("=");
|
||||
object[objectName][name.trim()] = value.join("=").trim();
|
||||
}
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
objectName = line.slice(1, -1);
|
||||
object[objectName] = {};
|
||||
} else {
|
||||
const [name, ...value] = line.split("=");
|
||||
object[objectName][name.trim()] = value.join("=").trim();
|
||||
}
|
||||
|
||||
return object;
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return {};
|
||||
}
|
||||
|
||||
return object;
|
||||
};
|
||||
|
||||
const jsonParse = (filePath: string) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
};
|
||||
|
||||
const processRazor1911 = (filePath: string): UnlockedAchievement[] => {
|
||||
try {
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
|
||||
const lines =
|
||||
fileContent.charCodeAt(0) === 0xfeff
|
||||
? fileContent.slice(1).split(/[\r\n]+/)
|
||||
: fileContent.split(/[\r\n]+/);
|
||||
const lines =
|
||||
fileContent.charCodeAt(0) === 0xfeff
|
||||
? fileContent.slice(1).split(/[\r\n]+/)
|
||||
: fileContent.split(/[\r\n]+/);
|
||||
|
||||
const achievements: UnlockedAchievement[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.length) continue;
|
||||
const achievements: UnlockedAchievement[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.length) continue;
|
||||
|
||||
const [name, unlocked, unlockTime] = line.split(" ");
|
||||
if (unlocked === "1") {
|
||||
achievements.push({
|
||||
name,
|
||||
unlockTime: Number(unlockTime) * 1000,
|
||||
});
|
||||
}
|
||||
const [name, unlocked, unlockTime] = line.split(" ");
|
||||
if (unlocked === "1") {
|
||||
achievements.push({
|
||||
name,
|
||||
unlockTime: Number(unlockTime) * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
return achievements;
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error processing ${filePath}`, err);
|
||||
return [];
|
||||
}
|
||||
|
||||
return achievements;
|
||||
};
|
||||
|
||||
const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface LibtorrentPayload {
|
||||
export interface ProcessPayload {
|
||||
exe: string;
|
||||
pid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PauseSeedingPayload {
|
||||
|
||||
@@ -153,21 +153,26 @@ export class HydraApi {
|
||||
(error) => {
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
const { config } = error;
|
||||
const data = JSON.parse(config.data);
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
omit(config.headers, ["accessToken", "refreshToken"]),
|
||||
Array.isArray(data)
|
||||
? data
|
||||
: omit(data, ["accessToken", "refreshToken"])
|
||||
);
|
||||
if (error.response) {
|
||||
logger.error(
|
||||
"Response",
|
||||
"Response error:",
|
||||
error.response.status,
|
||||
error.response.data
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
const errorData = error.toJSON();
|
||||
logger.error("Request error:", errorData.message);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,216 @@
|
||||
import { IsNull, Not } from "typeorm";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import type { GameRunning } from "@types";
|
||||
// import { PythonInstance } from "./download";
|
||||
import { Game } from "@main/entity";
|
||||
import axios from "axios";
|
||||
import { exec } from "child_process";
|
||||
|
||||
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 ""}'`,
|
||||
findWineExecutables: `lsof -c wine 2>/dev/null | grep '\\.exe$' | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
|
||||
};
|
||||
|
||||
export const gamesPlaytime = new Map<
|
||||
number,
|
||||
{ lastTick: number; firstTick: number; lastSyncTick: number }
|
||||
>();
|
||||
|
||||
interface ExecutableInfo {
|
||||
name: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
interface GameExecutables {
|
||||
[key: string]: ExecutableInfo[];
|
||||
}
|
||||
|
||||
const TICKS_TO_UPDATE_API = 120;
|
||||
let currentTick = 1;
|
||||
|
||||
const onGameTick = (game: Game) => {
|
||||
const gameExecutables = (
|
||||
await axios
|
||||
.get(
|
||||
import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL +
|
||||
"/game-executables.json"
|
||||
)
|
||||
.catch(() => {
|
||||
return { data: {} };
|
||||
})
|
||||
).data as GameExecutables;
|
||||
|
||||
const findGamePathByProcess = (
|
||||
processMap: Map<string, Set<string>>,
|
||||
gameId: string
|
||||
) => {
|
||||
const executables = gameExecutables[gameId].filter((info) => {
|
||||
if (process.platform === "linux" && info.os === "linux") return true;
|
||||
return info.os === "win32";
|
||||
});
|
||||
|
||||
for (const executable of executables) {
|
||||
const exe = executable.name.slice(executable.name.lastIndexOf("/") + 1);
|
||||
|
||||
if (!exe) continue;
|
||||
|
||||
const pathSet = processMap.get(exe);
|
||||
|
||||
if (pathSet) {
|
||||
const executableName =
|
||||
process.platform === "win32"
|
||||
? executable.name.replace(/\//g, "\\")
|
||||
: executable.name;
|
||||
|
||||
pathSet.forEach((path) => {
|
||||
if (path.toLowerCase().endsWith(executableName)) {
|
||||
gameRepository.update(
|
||||
{ objectID: gameId, shop: "steam" },
|
||||
{ executablePath: path }
|
||||
);
|
||||
|
||||
if (process.platform === "linux") {
|
||||
exec(commands.findWineDir, (err, out) => {
|
||||
if (err) return;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID: gameId, shop: "steam" },
|
||||
{
|
||||
winePrefixPath: out.trim().replace("/drive_c/windows", ""),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSystemProcessMap = async () => {
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
processes.forEach((process) => {
|
||||
const key = process.name.toLowerCase();
|
||||
const value = process.exe;
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const currentSet = map.get(key) ?? new Set();
|
||||
map.set(key, currentSet.add(value));
|
||||
});
|
||||
|
||||
if (process.platform === "linux") {
|
||||
await new Promise((res) => {
|
||||
exec(commands.findWineExecutables, (err, out) => {
|
||||
if (err) {
|
||||
res(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathSet = new Set(
|
||||
out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((path) => path.trim())
|
||||
);
|
||||
|
||||
pathSet.forEach((path) => {
|
||||
if (path.startsWith("/usr")) return;
|
||||
|
||||
const key = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
|
||||
|
||||
if (!key || !path) return;
|
||||
|
||||
const currentSet = map.get(key) ?? new Set();
|
||||
map.set(key, currentSet.add(path));
|
||||
});
|
||||
|
||||
res(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
export const watchProcesses = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!games.length) return;
|
||||
|
||||
const processMap = await getSystemProcessMap();
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath;
|
||||
|
||||
if (!executablePath) {
|
||||
if (gameExecutables[game.objectID]) {
|
||||
findGamePathByProcess(processMap, game.objectID);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const executable = executablePath
|
||||
.slice(
|
||||
executablePath.lastIndexOf(process.platform === "win32" ? "\\" : "/") +
|
||||
1
|
||||
)
|
||||
.toLowerCase();
|
||||
|
||||
const hasProcess = processMap.get(executable)?.has(executablePath);
|
||||
|
||||
if (hasProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
onTickGame(game);
|
||||
} else {
|
||||
onOpenGame(game);
|
||||
}
|
||||
} else if (gamesPlaytime.has(game.id)) {
|
||||
onCloseGame(game);
|
||||
}
|
||||
}
|
||||
|
||||
currentTick++;
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
|
||||
return {
|
||||
id: entry[0],
|
||||
sessionDurationInMillis: performance.now() - entry[1].firstTick,
|
||||
};
|
||||
});
|
||||
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-games-running",
|
||||
gamesRunning as Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function onOpenGame(game: Game) {
|
||||
const now = performance.now();
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
lastTick: now,
|
||||
firstTick: now,
|
||||
lastSyncTick: now,
|
||||
});
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date()).catch(() => {});
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function onTickGame(game: Game) {
|
||||
const now = performance.now();
|
||||
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export const getUserData = () => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return null;
|
||||
}
|
||||
logger.error("Failed to get logged user", err);
|
||||
logger.error("Failed to get logged user");
|
||||
const loggedUser = await userAuthRepository.findOne({
|
||||
where: { id: 1 },
|
||||
relations: { subscription: true },
|
||||
|
||||
@@ -85,7 +85,11 @@ export class WindowManager {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
if (details.url.includes("intercom.io")) {
|
||||
if (details.url.includes("featurebase")) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
if (details.url.includes("chatwoot")) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
@@ -191,7 +195,7 @@ export class WindowManager {
|
||||
this.mainWindow?.focus();
|
||||
}
|
||||
|
||||
public static createSystemTray(language: string) {
|
||||
public static async createSystemTray(language: string) {
|
||||
let tray: Tray;
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
@@ -259,6 +263,7 @@ export class WindowManager {
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
return contextMenu;
|
||||
};
|
||||
|
||||
@@ -270,6 +275,8 @@ export class WindowManager {
|
||||
tray.setToolTip("Hydra");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
await updateSystemTray();
|
||||
|
||||
tray.addListener("click", () => {
|
||||
if (this.mainWindow) {
|
||||
if (
|
||||
|
||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_ANALYTICS_API_URL: string;
|
||||
readonly MAIN_VITE_AUTH_URL: string;
|
||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user