mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-20 09:43:57 +00:00
Merge branch 'main' into feature/seed-completed-downloads
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user