Merge branch 'feature/game-achievements' into chore/test-preview

# Conflicts:
#	src/main/services/window-manager.ts
#	src/renderer/src/context/game-details/game-details.context.tsx
#	src/renderer/src/declaration.d.ts
#	src/types/index.ts
#	yarn.lock
This commit is contained in:
Zamitto 2024-10-02 15:16:43 -03:00
commit ef4844b8c0
44 changed files with 3311 additions and 1446 deletions

View File

@ -46,7 +46,7 @@
"archiver": "^7.0.1",
"auto-launch": "^5.0.6",
"axios": "^1.7.7",
"better-sqlite3": "^11.2.1",
"better-sqlite3": "^11.3.0",
"check-disk-space": "^3.4.0",
"classnames": "^2.5.1",
"color": "^4.2.3",
@ -54,8 +54,8 @@
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"dexie": "^4.0.8",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"electron-log": "^5.2.0",
"electron-updater": "^6.3.4",
"fetch-cookie": "^3.0.1",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
@ -104,7 +104,7 @@
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^30.3.0",
"electron-builder": "^24.9.1",
"electron-builder": "^25.1.6",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.8.0",

Binary file not shown.

View File

@ -240,7 +240,9 @@
"repack_count_one": "{{count}} repack added",
"repack_count_other": "{{count}} repacks added",
"new_update_available": "Version {{version}} available",
"restart_to_install_update": "Restart Hydra to install the update"
"restart_to_install_update": "Restart Hydra to install the update",
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked"
},
"system_tray": {
"open": "Open Hydra",
@ -327,5 +329,8 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked"
}
}

View File

@ -331,5 +331,8 @@
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada"
}
}

View File

@ -277,5 +277,8 @@
"friend_code_copied": "Código de amigo copiado",
"image_process_failure": "Falha ao processar a imagem",
"your_friend_code": "Seu código de amigo:"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada"
}
}

View File

@ -4,7 +4,12 @@ import path from "node:path";
export const defaultDownloadsPath = app.getPath("downloads");
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
export const databasePath = path.join(databaseDirectory, "hydra.db");
export const databasePath = path.join(
databaseDirectory,
import.meta.env.MAIN_VITE_API_URL.includes("staging")
? "hydra_test.db"
: "hydra.db"
);
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");

View File

@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
import { databasePath } from "./constants";
@ -21,6 +22,7 @@ export const dataSource = new DataSource({
DownloadSource,
DownloadQueue,
UserAuth,
GameAchievement,
],
synchronize: false,
database: databasePath,

View File

@ -0,0 +1,19 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("game_achievement")
export class GameAchievement {
@PrimaryGeneratedColumn()
id: number;
@Column("text")
objectId: string;
@Column("text")
shop: string;
@Column("text", { nullable: true })
unlockedAchievements: string;
@Column("text", { nullable: true })
achievements: string;
}

View File

@ -2,6 +2,8 @@ export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./game.entity";
export * from "./game-achievements.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";
export * from "./user-auth";

View File

@ -0,0 +1,94 @@
import type { GameAchievement, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import {
gameAchievementRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { UserNotLoggedInError } from "@shared";
import { Game } from "@main/entity";
const getAchievementsDataFromApi = async (
objectId: string,
shop: string,
game: Game | null
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return HydraApi.get("/games/achievements", {
objectId,
shop,
language: userPreferences?.language || "en",
})
.then((achievements) => {
if (game) {
gameAchievementRepository.upsert(
{
objectId,
shop,
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
}
return achievements;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) throw err;
return [];
});
};
const getGameAchievements = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
): Promise<GameAchievement[]> => {
const [game, cachedAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop },
}),
gameAchievementRepository.findOne({ where: { objectId, shop } }),
]);
const gameAchievements = cachedAchievements?.achievements
? JSON.parse(cachedAchievements.achievements)
: await getAchievementsDataFromApi(objectId, shop, game);
const unlockedAchievements = JSON.parse(
cachedAchievements?.unlockedAchievements || "[]"
) as { name: string; unlockTime: number }[];
return gameAchievements
.map((achievement) => {
const unlockedAchiement = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
achievement.name.toUpperCase()
);
}
);
if (unlockedAchiement) {
return {
...achievement,
unlocked: true,
unlockTime: unlockedAchiement.unlockTime,
};
}
return { ...achievement, unlocked: false, unlockTime: null };
})
.sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1;
if (!a.unlocked && b.unlocked) return 1;
return b.unlockTime - a.unlockTime;
});
};
registerEvent("getGameAchievements", getGameAchievements);

View File

@ -9,13 +9,10 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
const response = await HydraApi.get<GameStats>(
`/games/stats?${params.toString()}`
`/games/stats`,
{ objectId, shop },
{ needsAuth: false }
);
return response;
};

View File

@ -9,6 +9,7 @@ import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-game-achievements";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
import "./library/create-game-shortcut";

View File

@ -7,6 +7,7 @@ import type { GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -43,6 +44,8 @@ const addGameToLibrary = async (
});
}
updateLocalUnlockedAchivements(true, objectID);
const game = await gameRepository.findOne({ where: { objectID } });
createGame(game!).catch(() => {});

View File

@ -1,9 +1,17 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import { FriendRequestSync } from "@types";
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`);
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`).catch(
(err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequests: [] };
}
throw err;
}
);
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import { UserBlocks } from "@types";
export const getBlockedUsers = async (
@ -7,7 +8,12 @@ export const getBlockedUsers = async (
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip });
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { blocks: [] };
}
throw err;
});
};
registerEvent("getBlockedUsers", getBlockedUsers);

View File

@ -102,6 +102,7 @@ app.whenReady().then(async () => {
}
WindowManager.createMainWindow();
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});

View File

@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
import { app } from "electron";
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
export type HydraMigration = Knex.Migration & { name: string };
@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
UpdateUserLanguage,
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@ -0,0 +1,20 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateGameAchievement: HydraMigration = {
name: "CreateGameAchievement",
up: (knex: Knex) => {
return knex.schema.createTable("game_achievement", (table) => {
table.increments("id").primary();
table.text("objectId").notNullable();
table.text("shop").notNullable();
table.text("achievements");
table.text("unlockedAchievements");
table.unique(["objectId", "shop"]);
});
},
down: (knex: Knex) => {
return knex.schema.dropTable("game_achievement");
},
};

View File

@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@ -24,3 +25,6 @@ export const downloadSourceRepository =
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const gameAchievementRepository =
dataSource.getRepository(GameAchievement);

View File

@ -0,0 +1,14 @@
import { gameRepository } from "@main/repository";
import { startGameAchievementObserver as searchForAchievements } from "./game-achievements-observer";
export const watchAchievements = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
if (games.length === 0) return;
await searchForAchievements(games);
};

View File

@ -0,0 +1,82 @@
import { Cracker } from "@shared";
import type { UnlockedAchievement } from "@types";
export const checkUnlockedAchievements = (
type: Cracker,
unlockedAchievements: any
): UnlockedAchievement[] => {
if (type === Cracker.onlineFix) return onlineFixMerge(unlockedAchievements);
if (type === Cracker.goldberg)
return goldbergUnlockedAchievements(unlockedAchievements);
if (type == Cracker.generic) return genericMerge(unlockedAchievements);
return defaultMerge(unlockedAchievements);
};
const onlineFixMerge = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.timestamp,
});
}
}
return parsedUnlockedAchievements;
};
const goldbergUnlockedAchievements = (
unlockedAchievements: any
): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.earned) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.earned_time,
});
}
}
return newUnlockedAchievements;
};
const defaultMerge = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.Achieved) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.UnlockTime,
});
}
}
return newUnlockedAchievements;
};
const genericMerge = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.unlocked) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.time,
});
}
}
return newUnlockedAchievements;
};

View File

@ -0,0 +1,141 @@
import path from "node:path";
import fs from "node:fs";
import { app } from "electron";
import type { AchievementFile } from "@types";
import { Cracker } from "@shared";
import { Game } from "@main/entity";
//TODO: change to a automatized method
const publicDir = path.join("C:", "Users", "Public", "Documents");
const appData = app.getPath("appData");
const addGame = (
achievementFiles: Map<string, AchievementFile[]>,
achievementPath: string,
objectId: string,
fileLocation: string[],
type: Cracker
) => {
const filePath = path.join(achievementPath, objectId, ...fileLocation);
if (!fs.existsSync(filePath)) return;
const achivementFile = {
type,
filePath,
};
achievementFiles.get(objectId)
? achievementFiles.get(objectId)!.push(achivementFile)
: achievementFiles.set(objectId, [achivementFile]);
};
const getObjectIdsInFolder = (path: string) => {
if (fs.existsSync(path)) {
return fs.readdirSync(path);
}
return [];
};
export const findSteamGameAchievementFiles = (game: Game) => {
const crackers = [
Cracker.codex,
Cracker.goldberg,
Cracker.rune,
Cracker.onlineFix,
Cracker.generic,
];
const achievementFiles: AchievementFile[] = [];
for (const cracker of crackers) {
let achievementPath: string;
let fileLocation: string[];
if (cracker === Cracker.onlineFix) {
achievementPath = path.join(publicDir, Cracker.onlineFix);
fileLocation = ["Stats", "Achievements.ini"];
} else if (cracker === Cracker.goldberg) {
achievementPath = path.join(appData, "Goldberg SteamEmu Saves");
fileLocation = ["achievements.json"];
} else if (cracker === Cracker.generic) {
achievementPath = path.join(publicDir, Cracker.generic);
fileLocation = ["user_stats.ini"];
} else {
achievementPath = path.join(publicDir, "Steam", cracker);
fileLocation = ["achievements.ini"];
}
const filePath = path.join(achievementPath, game.objectID, ...fileLocation);
if (fs.existsSync(filePath)) {
achievementFiles.push({
type: cracker,
filePath: path.join(achievementPath, game.objectID, ...fileLocation),
});
}
}
return achievementFiles;
};
export const findAchievementFileInExecutableDirectory = (
game: Game
): AchievementFile | null => {
if (!game.executablePath) {
return null;
}
const steamDataPath = path.join(
game.executablePath,
"..",
"SteamData",
"user_stats.ini"
);
return {
type: Cracker.generic,
filePath: steamDataPath,
};
};
export const findAllSteamGameAchievementFiles = () => {
const gameAchievementFiles = new Map<string, AchievementFile[]>();
const crackers = [
Cracker.codex,
Cracker.goldberg,
Cracker.rune,
Cracker.onlineFix,
];
for (const cracker of crackers) {
let achievementPath: string;
let fileLocation: string[];
if (cracker === Cracker.onlineFix) {
achievementPath = path.join(publicDir, Cracker.onlineFix);
fileLocation = ["Stats", "Achievements.ini"];
} else if (cracker === Cracker.goldberg) {
achievementPath = path.join(appData, "Goldberg SteamEmu Saves");
fileLocation = ["achievements.json"];
} else {
achievementPath = path.join(publicDir, "Steam", cracker);
fileLocation = ["achievements.ini"];
}
const objectIds = getObjectIdsInFolder(achievementPath);
for (const objectId of objectIds) {
addGame(
gameAchievementFiles,
achievementPath,
objectId,
fileLocation,
cracker
);
}
}
return gameAchievementFiles;
};

View File

@ -0,0 +1,86 @@
import { checkUnlockedAchievements } from "./check-unlocked-achievements";
import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements";
import fs from "node:fs";
import {
findAchievementFileInExecutableDirectory,
findAllSteamGameAchievementFiles,
} from "./find-steam-game-achivement-files";
import type { AchievementFile } from "@types";
import { logger } from "../logger";
const fileStats: Map<string, number> = new Map();
const processAchievementFile = async (game: Game, file: AchievementFile) => {
const localAchievementFile = await parseAchievementFile(
file.filePath,
file.type
);
logger.log("Parsed achievements file", file.filePath, localAchievementFile);
if (localAchievementFile) {
const unlockedAchievements = checkUnlockedAchievements(
file.type,
localAchievementFile
);
logger.log("Achievements from file", file.filePath, unlockedAchievements);
if (unlockedAchievements.length) {
return mergeAchievements(
game.objectID,
game.shop,
unlockedAchievements,
true
);
}
}
};
const compareFile = async (game: Game, file: AchievementFile) => {
try {
const stat = fs.statSync(file.filePath);
const currentFileStat = fileStats.get(file.filePath);
fileStats.set(file.filePath, stat.mtimeMs);
if (!currentFileStat || currentFileStat === stat.mtimeMs) {
return;
}
logger.log(
"Detected change in file",
file.filePath,
stat.mtimeMs,
fileStats.get(file.filePath)
);
await processAchievementFile(game, file);
} catch (err) {
fileStats.set(file.filePath, -1);
}
};
export const startGameAchievementObserver = async (games: Game[]) => {
const achievementFiles = findAllSteamGameAchievementFiles();
for (const game of games) {
const gameAchievementFiles = achievementFiles.get(game.objectID) || [];
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
if (achievementFileInsideDirectory) {
gameAchievementFiles.push(achievementFileInsideDirectory);
}
if (!gameAchievementFiles.length) continue;
logger.log(
"Achievements files to observe for:",
game.title,
gameAchievementFiles
);
for (const file of gameAchievementFiles) {
compareFile(game, file);
}
}
};

View File

@ -0,0 +1,17 @@
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
export const getGameAchievementData = async (
objectId: string,
shop: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return HydraApi.get("/games/achievements", {
shop,
objectId,
language: userPreferences?.language || "en",
});
};

View File

@ -0,0 +1,116 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import type { GameShop, UnlockedAchievement } from "@types";
import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api";
const saveAchievementsOnLocal = async (
objectId: string,
shop: string,
achievements: any[]
) => {
return gameAchievementRepository
.upsert(
{
objectId,
shop,
unlockedAchievements: JSON.stringify(achievements),
},
["objectId", "shop"]
)
.then(() => {
WindowManager.mainWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop
);
});
};
export const mergeAchievements = async (
objectId: string,
shop: string,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
const game = await gameRepository.findOne({
where: { objectID: objectId, shop: shop as GameShop },
});
if (!game) return;
const localGameAchievement = await gameAchievementRepository.findOne({
where: {
objectId,
shop,
},
});
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
);
const newAchievements = achievements
.filter((achievement) => {
return !unlockedAchievements.some((localAchievement) => {
return localAchievement.name === achievement.name.toUpperCase();
});
})
.map((achievement) => {
return {
name: achievement.name.toUpperCase(),
unlockTime: achievement.unlockTime * 1000,
};
});
if (newAchievements.length && publishNotification) {
const achievementsInfo = newAchievements
.map((achievement) => {
return JSON.parse(localGameAchievement?.achievements || "[]").find(
(steamAchievement) => {
return achievement.name === steamAchievement.name;
}
);
})
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement.displayName,
iconUrl: achievement.icon,
};
});
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop,
achievementsInfo
);
WindowManager.notificationWindow?.setBounds({ y: 50 });
setTimeout(() => {
WindowManager.notificationWindow?.setBounds({ y: -9999 });
}, 4000);
}
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (game?.remoteId) {
return HydraApi.put("/profile/games/achievements", {
id: game.remoteId,
achievements: mergedLocalAchievements,
})
.then((response) => {
return saveAchievementsOnLocal(
response.objectId,
response.shop,
response.achievements
);
})
.catch(() => {
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
});
}
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
};

View File

@ -0,0 +1,103 @@
import { Cracker } from "@shared";
import { existsSync, createReadStream, readFileSync } from "node:fs";
import readline from "node:readline";
export const parseAchievementFile = async (
filePath: string,
type: Cracker
): Promise<any | null> => {
if (existsSync(filePath)) {
if (type === Cracker.generic) {
return genericParse(filePath);
}
if (filePath.endsWith(".ini")) {
return iniParse(filePath);
}
if (filePath.endsWith(".json")) {
return jsonParse(filePath);
}
}
};
const genericParse = async (filePath: string) => {
try {
const file = createReadStream(filePath);
const lines = readline.createInterface({
input: file,
crlfDelay: Infinity,
});
const object: Record<string, Record<string, string | number>> = {};
for await (const line of lines) {
if (line.startsWith("###") || !line.length) continue;
if (line.startsWith("[") && line.endsWith("]")) {
continue;
}
const [name, ...value] = line.split(" = ");
const objectName = name.slice(1, -1);
object[objectName] = {};
const joinedValue = value.join("=").slice(1, -1);
for (const teste of joinedValue.split(",")) {
const [name, value] = teste.split("=");
object[objectName][name.trim()] = value;
}
}
console.log(object);
return object;
} catch {
return null;
}
};
const iniParse = async (filePath: string) => {
try {
const file = createReadStream(filePath);
const lines = readline.createInterface({
input: file,
crlfDelay: Infinity,
});
let objectName = "";
const object: Record<string, Record<string, string | number>> = {};
for await (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("=");
console.log(line);
console.log(name, value);
const joinedValue = value.join("").trim();
const number = Number(joinedValue);
object[objectName][name.trim()] = isNaN(number) ? joinedValue : number;
}
}
return object;
} catch {
return null;
}
};
const jsonParse = (filePath: string) => {
try {
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
return null;
}
};

View File

@ -0,0 +1,123 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import {
findAllSteamGameAchievementFiles,
findSteamGameAchievementFiles,
} from "./find-steam-game-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { checkUnlockedAchievements } from "./check-unlocked-achievements";
import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types";
import { getGameAchievementData } from "./get-game-achievement-data";
export const updateAllLocalUnlockedAchievements = async () => {
const gameAchievementFilesMap = findAllSteamGameAchievementFiles();
for (const objectId of gameAchievementFilesMap.keys()) {
const gameAchievementFiles = gameAchievementFilesMap.get(objectId)!;
const [game, localAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop: "steam", isDeleted: false },
}),
gameAchievementRepository.findOne({
where: { objectId, shop: "steam" },
}),
]);
if (!game) continue;
console.log("Achievements files for", game.title, gameAchievementFiles);
if (!localAchievements || !localAchievements.achievements) {
await getGameAchievementData(objectId, "steam")
.then((achievements) => {
return gameAchievementRepository.upsert(
{
objectId,
shop: "steam",
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
})
.catch(() => {});
}
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const localAchievementFile = await parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (localAchievementFile) {
unlockedAchievements.push(
...checkUnlockedAchievements(
achievementFile.type,
localAchievementFile
)
);
}
}
mergeAchievements(objectId, "steam", unlockedAchievements, false);
}
};
export const updateLocalUnlockedAchivements = async (
publishNotification: boolean,
objectId: string
) => {
const [game, localAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop: "steam", isDeleted: false },
}),
gameAchievementRepository.findOne({
where: { objectId, shop: "steam" },
}),
]);
if (!game) return;
const gameAchievementFiles = findSteamGameAchievementFiles(game);
console.log("Achievements files for", game.title, gameAchievementFiles);
if (!localAchievements || !localAchievements.achievements) {
await getGameAchievementData(objectId, "steam")
.then((achievements) => {
return gameAchievementRepository.upsert(
{
objectId,
shop: "steam",
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
})
.catch(() => {});
}
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const localAchievementFile = await parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (localAchievementFile) {
unlockedAchievements.push(
...checkUnlockedAchievements(achievementFile.type, localAchievementFile)
);
}
}
mergeAchievements(
objectId,
"steam",
unlockedAchievements,
publishNotification
);
};

View File

@ -17,6 +17,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@ -87,6 +88,7 @@ export class HydraApi {
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
});
if (this.ADD_LOG_INTERCEPTOR) {
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
@ -130,7 +132,11 @@ export class HydraApi {
);
if (error.response) {
logger.error("Response", error.response.status, error.response.data);
logger.error(
"Response",
error.response.status,
error.response.data
);
} else if (error.request) {
logger.error("Request", error.request);
} else {
@ -141,6 +147,7 @@ export class HydraApi {
return Promise.reject(error);
}
);
}
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },

View File

@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api";
import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager";
import { updateAllLocalUnlockedAchievements } from "../achievements/update-local-unlocked-achivements";
export const uploadGamesBatch = async () => {
const games = await gameRepository.find({
@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
await mergeWithRemoteGames();
await updateAllLocalUnlockedAchievements();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
};

View File

@ -1,6 +1,7 @@
import { sleep } from "@main/helpers";
import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
import { watchAchievements } from "./achievements/achievement-watcher";
export const startMainLoop = async () => {
// eslint-disable-next-line no-constant-condition
@ -8,6 +9,7 @@ export const startMainLoop = async () => {
await Promise.allSettled([
watchProcesses(),
DownloadManager.watchDownloads(),
watchAchievements(),
]);
await sleep(1000);

View File

@ -25,12 +25,12 @@ export const watchProcesses = async () => {
if (games.length === 0) return;
const processes = await PythonInstance.getProcessList();
const processSet = new Set(processes.map((process) => process.exe));
for (const game of games) {
const executablePath = game.executablePath!;
const gameProcess = processes.find((runningProcess) => {
return executablePath == runningProcess.exe;
});
const gameProcess = processSet.has(executablePath);
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {

View File

@ -19,8 +19,9 @@ import { HydraApi } from "./hydra-api";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
private static loadURL(hash = "") {
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
@ -37,6 +38,21 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
}
}
public static createMainWindow() {
if (this.mainWindow) return;
@ -108,7 +124,7 @@ export class WindowManager {
}
);
this.loadURL();
this.loadMainWindowURL();
this.mainWindow.removeMenu();
this.mainWindow.on("ready-to-show", () => {
@ -125,9 +141,36 @@ export class WindowManager {
app.quit();
}
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null;
});
}
public static createNotificationWindow() {
this.notificationWindow = new BrowserWindow({
transparent: true,
maximizable: false,
autoHideMenuBar: true,
minimizable: false,
focusable: true,
skipTaskbar: true,
frame: false,
width: 240,
height: 60,
x: 25,
y: -9999,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.notificationWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
}
public static openAuthWindow() {
if (this.mainWindow) {
const authWindow = new BrowserWindow({
@ -174,7 +217,7 @@ export class WindowManager {
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadURL(hash);
this.loadMainWindowURL(hash);
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
this.mainWindow?.focus();

View File

@ -50,6 +50,25 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
getGameAchievements: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAchievements", objectId, shop),
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => cb(objectId, shop, achievements);
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),

Binary file not shown.

View File

@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type {
Game,
GameAchievement,
GameRepack,
GameShop,
GameStats,
@ -36,6 +37,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
showRepacksModal: false,
showGameOptionsModal: false,
stats: null,
achievements: [],
hasNSFWContentBlocked: false,
setGameColor: () => {},
selectGameExecutable: async () => null,
@ -62,6 +64,7 @@ export function GameDetailsContextProvider({
shop,
}: GameDetailsContextProps) {
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -133,6 +136,15 @@ export function GameDetailsContextProvider({
setStats(result);
});
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.then((achievements) => {
setAchievements(achievements);
})
.catch(() => {
// TODO: handle user not logged in error
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
@ -141,6 +153,7 @@ export function GameDetailsContextProvider({
setGame(null);
setIsLoading(true);
setisGameRunning(false);
setAchievements([]);
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
@ -161,6 +174,23 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(objectId, shop) => {
if (objectId !== objectId || shop !== shop) return;
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.then(setAchievements)
.catch(() => {});
}
);
return () => {
unsubscribe();
};
}, [objectId, shop]);
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@ -204,6 +234,7 @@ export function GameDetailsContextProvider({
showGameOptionsModal,
showRepacksModal,
stats,
achievements,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
setGameColor,

View File

@ -1,5 +1,6 @@
import type {
Game,
GameAchievement,
GameRepack,
GameShop,
GameStats,
@ -19,6 +20,7 @@ export interface GameDetailsContext {
showRepacksModal: boolean;
showGameOptionsModal: boolean;
stats: GameStats | null;
achievements: GameAchievement[];
hasNSFWContentBlocked: boolean;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;

View File

@ -25,6 +25,7 @@ import type {
UserStats,
UserDetails,
FriendRequestSync,
GameAchievement,
GameArtifact,
LudusaviBackup,
} from "@types";
@ -68,6 +69,17 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
getGameAchievements: (
objectId: string,
shop: GameShop
) => Promise<GameAchievement[]>;
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => () => Electron.IpcRenderer;
/* Library */
addGameToLibrary: (

View File

@ -1,4 +1,4 @@
import { formatDistance, subMilliseconds } from "date-fns";
import { format, formatDistance, subMilliseconds } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import {
ptBR,
@ -67,5 +67,13 @@ export function useDate() {
return "";
}
},
format: (timestamp: number): string => {
const locale = getDateLocale();
return format(
timestamp,
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
);
},
};
}

View File

@ -28,6 +28,7 @@ import {
import { store } from "./store";
import resources from "@locales";
import { Achievemnt } from "./pages/achievement/achievement";
import "./workers";
import { RepacksContextProvider } from "./context";
@ -69,6 +70,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievemnt} />
</Routes>
</HashRouter>
</RepacksContextProvider>

View File

@ -0,0 +1,64 @@
import { useEffect, useMemo, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
export function Achievemnt() {
const { t } = useTranslation("achievement");
const [achievementInfo, setAchievementInfo] = useState<{
displayName: string;
icon: string;
} | null>(null);
const audio = useMemo(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.preload = "auto";
return audio;
}, []);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(_object, _shop, achievements) => {
if (!achievements) return;
if (achievements.length) {
const achievement = achievements[0];
setAchievementInfo({
displayName: achievement.displayName,
icon: achievement.iconUrl,
});
}
audio.play();
}
);
return () => {
unsubscribe();
};
}, [audio]);
if (!achievementInfo) return <p>Nada</p>;
return (
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<img
src={achievementInfo.icon}
alt={achievementInfo.displayName}
style={{ width: 60, height: 60 }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{achievementInfo.displayName}</p>
</div>
</div>
);
}

View File

@ -5,8 +5,9 @@ import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useDate, useFormat } from "@renderer/hooks";
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
export function Sidebar() {
const [_howLongToBeat, _setHowLongToBeat] = useState<{
@ -17,9 +18,11 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, stats } = useContext(gameDetailsContext);
const { gameTitle, shopDetails, stats, achievements } =
useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { format } = useDate();
const { numberFormatter } = useFormat();
@ -45,6 +48,47 @@ export function Sidebar() {
isLoading={howLongToBeat.isLoading}
/> */}
{achievements.length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
>
{achievements.map((achievement, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
title={achievement.description}
>
<img
style={{
height: "72px",
width: "72px",
filter: achievement.unlocked ? "none" : "grayscale(100%)",
}}
src={
achievement.unlocked ? achievement.icon : achievement.icongray
}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>
{achievement.unlockTime && format(achievement.unlockTime)}
</div>
</div>
))}
</div>
)}
{stats && (
<>
<div

View File

@ -23,3 +23,11 @@ export enum SteamContentDescriptor {
FrequentNudityOrSexualContent = 4,
GeneralMatureContent = 5,
}
export enum Cracker {
codex = "CODEX",
rune = "RUNE",
onlineFix = "OnlineFix",
goldberg = "Goldberg",
generic = "Generic",
}

View File

@ -1,4 +1,4 @@
import type { DownloadSourceStatus, Downloader } from "@shared";
import type { Cracker, DownloadSourceStatus, Downloader } from "@shared";
import type { SteamAppDetails } from "./steam.types";
export type GameStatus =
@ -28,6 +28,16 @@ export interface GameRepack {
updatedAt: Date;
}
export interface GameAchievement {
name: string;
displayName: string;
description?: string;
unlocked: boolean;
unlockTime: number | null;
icon: string;
icongray: string;
}
export type ShopDetails = SteamAppDetails & {
objectID: string;
};
@ -266,6 +276,20 @@ export interface UserStats {
friendsCount: number;
}
export interface UnlockedAchievement {
name: string;
unlockTime: number;
}
export interface AchievementFile {
type: Cracker;
filePath: string;
}
export type GameAchievementFiles = {
[id: string]: AchievementFile[];
};
export interface GameArtifact {
id: string;
artifactLengthInBytes: number;

3479
yarn.lock

File diff suppressed because it is too large Load Diff