mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-25 03:41:02 +00:00
365 lines
9.7 KiB
TypeScript
365 lines
9.7 KiB
TypeScript
import { parseAchievementFile } from "./parse-achievement-file";
|
|
import { mergeAchievements } from "./merge-achievements";
|
|
import fs, { readdirSync } from "node:fs";
|
|
import {
|
|
findAchievementFileInExecutableDirectory,
|
|
findAchievementFileInSteamPath,
|
|
findAchievementFiles,
|
|
findAllAchievementFiles,
|
|
getAlternativeObjectIds,
|
|
} from "./find-achivement-files";
|
|
import type {
|
|
AchievementFile,
|
|
Game,
|
|
GameShop,
|
|
UnlockedAchievement,
|
|
UserPreferences,
|
|
} from "@types";
|
|
import { achievementsLogger } from "../logger";
|
|
import { Cracker } from "@shared";
|
|
import { publishCombinedNewAchievementNotification } from "../notifications";
|
|
import { db, gamesSublevel, levelKeys } from "@main/level";
|
|
import { WindowManager } from "../window-manager";
|
|
import { setTimeout } from "node:timers/promises";
|
|
|
|
const fileStats: Map<string, number> = new Map();
|
|
const fltFiles: Map<string, Set<string>> = new Map();
|
|
|
|
const watchAchievementsWindows = async () => {
|
|
const games = await gamesSublevel
|
|
.values()
|
|
.all()
|
|
.then((games) => games.filter((game) => !game.isDeleted));
|
|
|
|
if (games.length === 0) return;
|
|
|
|
const achievementFiles = findAllAchievementFiles();
|
|
|
|
for (const game of games) {
|
|
const gameAchievementFiles: AchievementFile[] = [];
|
|
|
|
for (const objectId of getAlternativeObjectIds(game.objectId)) {
|
|
gameAchievementFiles.push(...(achievementFiles.get(objectId) ?? []));
|
|
|
|
gameAchievementFiles.push(
|
|
...findAchievementFileInExecutableDirectory(game)
|
|
);
|
|
|
|
gameAchievementFiles.push(
|
|
...(await findAchievementFileInSteamPath(game))
|
|
);
|
|
}
|
|
|
|
for (const file of gameAchievementFiles) {
|
|
await compareFile(game, file);
|
|
}
|
|
}
|
|
};
|
|
|
|
const watchAchievementsWithWine = async () => {
|
|
const games = await gamesSublevel
|
|
.values()
|
|
.all()
|
|
.then((games) =>
|
|
games.filter((game) => !game.isDeleted && game.winePrefixPath)
|
|
);
|
|
|
|
for (const game of games) {
|
|
const gameAchievementFiles = findAchievementFiles(game);
|
|
|
|
gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game)));
|
|
|
|
for (const file of gameAchievementFiles) {
|
|
await compareFile(game, file);
|
|
}
|
|
}
|
|
};
|
|
|
|
const compareFltFolder = async (game: Game, file: AchievementFile) => {
|
|
try {
|
|
const currentAchievements = new Set(readdirSync(file.filePath));
|
|
const previousAchievements = fltFiles.get(file.filePath);
|
|
|
|
fltFiles.set(file.filePath, currentAchievements);
|
|
if (
|
|
!previousAchievements ||
|
|
currentAchievements.difference(previousAchievements).size === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
achievementsLogger.log("Detected change in FLT folder", file.filePath);
|
|
await processAchievementFileDiff(game, file);
|
|
} catch (err) {
|
|
achievementsLogger.error(err);
|
|
fltFiles.set(file.filePath, new Set());
|
|
}
|
|
};
|
|
|
|
const compareFile = (game: Game, file: AchievementFile) => {
|
|
if (file.type === Cracker.flt) {
|
|
return compareFltFolder(game, file);
|
|
}
|
|
|
|
try {
|
|
const currentStat = fs.statSync(file.filePath);
|
|
const previousStat = fileStats.get(file.filePath);
|
|
fileStats.set(file.filePath, currentStat.mtimeMs);
|
|
|
|
if (!previousStat || previousStat === -1) {
|
|
if (currentStat.mtimeMs) {
|
|
achievementsLogger.log(
|
|
"First change in file",
|
|
file.filePath,
|
|
previousStat,
|
|
currentStat.mtimeMs
|
|
);
|
|
|
|
return processAchievementFileDiff(game, file);
|
|
}
|
|
}
|
|
|
|
if (previousStat === currentStat.mtimeMs) {
|
|
return;
|
|
}
|
|
|
|
achievementsLogger.log(
|
|
"Detected change in file",
|
|
file.filePath,
|
|
previousStat,
|
|
currentStat.mtimeMs
|
|
);
|
|
return processAchievementFileDiff(game, file);
|
|
} catch (err) {
|
|
achievementsLogger.error(
|
|
"Error reading file",
|
|
file.filePath,
|
|
err instanceof Error ? err.message : err
|
|
);
|
|
fileStats.set(file.filePath, -1);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const processAchievementFileDiff = async (
|
|
game: Game,
|
|
file: AchievementFile
|
|
) => {
|
|
const parsedAchievements = parseAchievementFile(file.filePath, file.type);
|
|
|
|
if (parsedAchievements.length) {
|
|
return mergeAchievements(game, parsedAchievements, true);
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
export class AchievementWatcherManager {
|
|
private static _hasFinishedPreSearch = false;
|
|
|
|
public static get hasFinishedPreSearch() {
|
|
return this._hasFinishedPreSearch;
|
|
}
|
|
|
|
public static readonly alreadySyncedGames: Map<string, boolean> = new Map();
|
|
|
|
public static async firstSyncWithRemoteIfNeeded(
|
|
shop: GameShop,
|
|
objectId: string
|
|
) {
|
|
if (shop === "custom") return;
|
|
|
|
const gameKey = levelKeys.game(shop, objectId);
|
|
if (this.alreadySyncedGames.get(gameKey)) return;
|
|
|
|
this.alreadySyncedGames.set(gameKey, true);
|
|
|
|
const game = await gamesSublevel.get(gameKey).catch(() => null);
|
|
if (!game || game.isDeleted) return;
|
|
|
|
const gameAchievementFiles = findAchievementFiles(game);
|
|
|
|
gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game)));
|
|
|
|
const unlockedAchievements: UnlockedAchievement[] = [];
|
|
|
|
for (const achievementFile of gameAchievementFiles) {
|
|
const localAchievementFile = parseAchievementFile(
|
|
achievementFile.filePath,
|
|
achievementFile.type
|
|
);
|
|
|
|
if (localAchievementFile.length) {
|
|
unlockedAchievements.push(...localAchievementFile);
|
|
}
|
|
}
|
|
|
|
const newAchievements = await mergeAchievements(
|
|
game,
|
|
unlockedAchievements,
|
|
false
|
|
);
|
|
|
|
if (newAchievements > 0) {
|
|
this.notifyCombinedAchievementsUnlocked(1, newAchievements);
|
|
}
|
|
}
|
|
|
|
public static watchAchievements() {
|
|
if (!this.hasFinishedPreSearch) return;
|
|
|
|
if (process.platform === "win32") {
|
|
return watchAchievementsWindows();
|
|
}
|
|
|
|
return watchAchievementsWithWine();
|
|
}
|
|
|
|
private static preProcessGameAchievementFiles(
|
|
game: Game,
|
|
gameAchievementFiles: AchievementFile[]
|
|
) {
|
|
const unlockedAchievements: UnlockedAchievement[] = [];
|
|
for (const achievementFile of gameAchievementFiles) {
|
|
const parsedAchievements = parseAchievementFile(
|
|
achievementFile.filePath,
|
|
achievementFile.type
|
|
);
|
|
|
|
try {
|
|
const currentStat = fs.statSync(achievementFile.filePath);
|
|
fileStats.set(achievementFile.filePath, currentStat.mtimeMs);
|
|
} catch {
|
|
fileStats.set(achievementFile.filePath, -1);
|
|
}
|
|
|
|
if (parsedAchievements.length) {
|
|
unlockedAchievements.push(...parsedAchievements);
|
|
|
|
achievementsLogger.log(
|
|
"Achievement file for",
|
|
game.title,
|
|
achievementFile.filePath,
|
|
parsedAchievements
|
|
);
|
|
}
|
|
}
|
|
|
|
if (unlockedAchievements.length) {
|
|
return mergeAchievements(game, unlockedAchievements, false);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static async getGameAchievementFilesWindows() {
|
|
const games = await gamesSublevel
|
|
.values()
|
|
.all()
|
|
.then((games) => games.filter((game) => !game.isDeleted));
|
|
|
|
const gameAchievementFilesMap = findAllAchievementFiles();
|
|
|
|
return Promise.all(
|
|
games.map(async (game) => {
|
|
const achievementFiles: AchievementFile[] = [];
|
|
|
|
for (const objectId of getAlternativeObjectIds(game.objectId)) {
|
|
achievementFiles.push(
|
|
...(gameAchievementFilesMap.get(objectId) || [])
|
|
);
|
|
|
|
achievementFiles.push(
|
|
...findAchievementFileInExecutableDirectory(game)
|
|
);
|
|
|
|
achievementFiles.push(
|
|
...(await findAchievementFileInSteamPath(game))
|
|
);
|
|
}
|
|
|
|
return { game, achievementFiles };
|
|
})
|
|
);
|
|
}
|
|
|
|
private static async getGameAchievementFilesLinux() {
|
|
const games = await gamesSublevel
|
|
.values()
|
|
.all()
|
|
.then((games) => games.filter((game) => !game.isDeleted));
|
|
|
|
return Promise.all(
|
|
games.map(async (game) => {
|
|
const achievementFiles = findAchievementFiles(game);
|
|
|
|
achievementFiles.push(...(await findAchievementFileInSteamPath(game)));
|
|
|
|
return { game, achievementFiles };
|
|
})
|
|
);
|
|
}
|
|
|
|
private static async notifyCombinedAchievementsUnlocked(
|
|
totalNewGamesWithAchievements: number,
|
|
totalNewAchievements: number
|
|
) {
|
|
const userPreferences = await db.get<string, UserPreferences>(
|
|
levelKeys.userPreferences,
|
|
{
|
|
valueEncoding: "json",
|
|
}
|
|
);
|
|
|
|
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
|
|
WindowManager.notificationWindow?.webContents.send(
|
|
"on-combined-achievements-unlocked",
|
|
totalNewGamesWithAchievements,
|
|
totalNewAchievements,
|
|
userPreferences.achievementCustomNotificationPosition ?? "top-left"
|
|
);
|
|
} else {
|
|
publishCombinedNewAchievementNotification(
|
|
totalNewAchievements,
|
|
totalNewGamesWithAchievements
|
|
);
|
|
}
|
|
}
|
|
|
|
public static async preSearchAchievements() {
|
|
try {
|
|
const gameAchievementFiles =
|
|
process.platform === "win32"
|
|
? await this.getGameAchievementFilesWindows()
|
|
: await this.getGameAchievementFilesLinux();
|
|
|
|
const newAchievementsCount = await Promise.all(
|
|
gameAchievementFiles.map(({ game, achievementFiles }) => {
|
|
return this.preProcessGameAchievementFiles(game, achievementFiles);
|
|
})
|
|
);
|
|
|
|
const totalNewGamesWithAchievements = newAchievementsCount.filter(
|
|
(achievements) => achievements
|
|
).length;
|
|
|
|
const totalNewAchievements = newAchievementsCount.reduce(
|
|
(acc, val) => acc + val,
|
|
0
|
|
);
|
|
|
|
if (totalNewAchievements > 0) {
|
|
await setTimeout(4000);
|
|
this.notifyCombinedAchievementsUnlocked(
|
|
totalNewGamesWithAchievements,
|
|
totalNewAchievements
|
|
);
|
|
}
|
|
} catch (err) {
|
|
achievementsLogger.error("Error on preSearchAchievements", err);
|
|
}
|
|
|
|
this._hasFinishedPreSearch = true;
|
|
}
|
|
}
|