feat: adding change hero

This commit is contained in:
Chubby Granny Chaser 2024-10-05 02:25:46 +01:00
commit 58502aeb1f
No known key found for this signature in database
47 changed files with 3572 additions and 1461 deletions

View File

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.1.7",
"version": "2.1.7-preview",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@ -44,7 +44,7 @@
"@vanilla-extract/recipes": "^0.5.2",
"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",
@ -52,8 +52,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",
@ -101,7 +101,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,15 +9,11 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
const response = await HydraApi.get<GameStats>(
`/games/stats?${params.toString()}`
return HydraApi.get<GameStats>(
`/games/stats`,
{ objectId, shop },
{ needsAuth: false }
);
return response;
};
registerEvent("getGameStats", getGameStats);

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(objectId);
const game = await gameRepository.findOne({
where: { objectID: objectId },
});

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,81 @@
import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements";
import fs from "node:fs";
import {
findAchievementFileInExecutableDirectory,
findAllAchievementFiles,
} from "./find-achivement-files";
import type { AchievementFile } from "@types";
import { logger } from "../logger";
const fileStats: Map<string, number> = new Map();
const processAchievementFileDiff = async (
game: Game,
file: AchievementFile
) => {
const unlockedAchievements = await parseAchievementFile(
file.filePath,
file.type
);
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 processAchievementFileDiff(game, file);
} catch (err) {
fileStats.set(file.filePath, -1);
}
};
export const checkAchievementFileChange = async (games: Game[]) => {
const achievementFiles = await findAllAchievementFiles();
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,14 @@
import { gameRepository } from "@main/repository";
import { checkAchievementFileChange as searchForAchievements } from "./achievement-file-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,215 @@
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";
import { achievementsLogger } from "../logger";
//TODO: change to a automatized method
const publicDocuments = path.join("C:", "Users", "Public", "Documents");
const programData = path.join("C:", "ProgramData");
const appData = app.getPath("appData");
const documents = app.getPath("documents");
const localAppData = path.join(appData, "..", "Local");
const crackers = [
Cracker.codex,
Cracker.goldberg,
Cracker.rune,
Cracker.onlineFix,
Cracker.userstats,
Cracker.rld,
Cracker.creamAPI,
Cracker.skidrow,
Cracker.smartSteamEmu,
Cracker.empress,
];
const getPathFromCracker = async (cracker: Cracker) => {
if (cracker === Cracker.codex) {
return [
{
folderPath: path.join(publicDocuments, "Steam", "CODEX"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(appData, "Steam", "CODEX"),
fileLocation: ["achievements.ini"],
},
];
}
if (cracker === Cracker.rune) {
return [
{
folderPath: path.join(publicDocuments, "Steam", "RUNE"),
fileLocation: ["achievements.ini"],
},
];
}
if (cracker === Cracker.onlineFix) {
return [
{
folderPath: path.join(publicDocuments, Cracker.onlineFix),
fileLocation: ["Stats", "Achievements.ini"],
},
];
}
if (cracker === Cracker.goldberg) {
return [
{
folderPath: path.join(appData, "Goldberg SteamEmu Saves"),
fileLocation: ["achievements.json"],
},
{
folderPath: path.join(appData, "GSE Saves"),
fileLocation: ["achievements.json"],
},
];
}
if (cracker === Cracker.userstats) {
return [];
}
if (cracker === Cracker.rld) {
return [
{
folderPath: path.join(programData, "RLD!"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(programData, "Steam", "Player"),
fileLocation: ["stats", "achievements.ini"],
},
];
}
if (cracker === Cracker.empress) {
return [
{
folderPath: path.join(appData, "EMPRESS", "remote"),
fileLocation: ["achievements.json"],
},
{
folderPath: path.join(publicDocuments, "EMPRESS", "remote"),
fileLocation: ["achievements.json"],
},
];
}
if (cracker === Cracker.skidrow) {
return [
{
folderPath: path.join(documents, "SKIDROW"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
{
folderPath: path.join(documents, "Player"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
{
folderPath: path.join(localAppData, "SKIDROW"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
];
}
if (cracker === Cracker.creamAPI) {
return [
{
folderPath: path.join(appData, "CreamAPI"),
fileLocation: ["stats", "CreamAPI.Achievements.cfg"],
},
];
}
if (cracker === Cracker.smartSteamEmu) {
return [
{
folderPath: path.join(appData, "SmartSteamEmu"),
fileLocation: ["User", "Achievements"],
},
];
}
achievementsLogger.error(`Cracker ${cracker} not implemented`);
throw new Error(`Cracker ${cracker} not implemented`);
};
export const findAchievementFiles = async (game: Game) => {
const achievementFiles: AchievementFile[] = [];
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of await getPathFromCracker(
cracker
)) {
const filePath = path.join(folderPath, game.objectID, ...fileLocation);
if (fs.existsSync(filePath)) {
achievementFiles.push({
type: cracker,
filePath,
});
}
}
}
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.userstats,
filePath: steamDataPath,
};
};
export const findAllAchievementFiles = async () => {
const gameAchievementFiles = new Map<string, AchievementFile[]>();
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of await getPathFromCracker(
cracker
)) {
if (!fs.existsSync(folderPath)) {
continue;
}
const objectIds = fs.readdirSync(folderPath);
for (const objectId of objectIds) {
const filePath = path.join(folderPath, objectId, ...fileLocation);
if (!fs.existsSync(filePath)) continue;
const achivementFile = {
type: cracker,
filePath,
};
gameAchievementFiles.get(objectId)
? gameAchievementFiles.get(objectId)!.push(achivementFile)
: gameAchievementFiles.set(objectId, [achivementFile]);
}
}
}
return gameAchievementFiles;
};

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,118 @@
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.toUpperCase() === achievement.name.toUpperCase()
);
});
})
.map((achievement) => {
return {
name: achievement.name.toUpperCase(),
unlockTime: achievement.unlockTime * 1000,
};
});
if (newAchievements.length && publishNotification) {
const achievementsInfo = newAchievements
.sort((a, b) => {
return a.unlockTime - b.unlockTime;
})
.map((achievement) => {
return JSON.parse(localGameAchievement?.achievements || "[]").find(
(steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
}
);
})
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement.displayName,
iconUrl: achievement.icon,
};
});
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop,
achievementsInfo
);
}
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,205 @@
import { Cracker } from "@shared";
import { UnlockedAchievement } from "@types";
import { existsSync, createReadStream, readFileSync } from "node:fs";
import readline from "node:readline";
import { achievementsLogger } from "../logger";
export const parseAchievementFile = async (
filePath: string,
type: Cracker
): Promise<UnlockedAchievement[]> => {
if (!existsSync(filePath)) return [];
if (type == Cracker.codex) {
const parsed = await iniParse(filePath);
return processDefault(parsed);
}
if (type == Cracker.rune) {
const parsed = await iniParse(filePath);
return processDefault(parsed);
}
if (type === Cracker.onlineFix) {
const parsed = await iniParse(filePath);
return processOnlineFix(parsed);
}
if (type === Cracker.goldberg) {
const parsed = await jsonParse(filePath);
return processGoldberg(parsed);
}
if (type == Cracker.userstats) {
const parsed = await iniParse(filePath);
return processUserStats(parsed);
}
if (type == Cracker.rld) {
const parsed = await iniParse(filePath);
return processRld(parsed);
}
if (type === Cracker.skidrow) {
const parsed = await iniParse(filePath);
return processSkidrow(parsed);
}
achievementsLogger.log(`${type} achievements found on ${filePath}`);
return [];
};
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("=");
object[objectName][name.trim()] = value.join("").trim();
}
}
console.log("Parsed ini", object);
return object;
} catch {
return null;
}
};
const jsonParse = (filePath: string) => {
try {
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
return null;
}
};
const processOnlineFix = (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 processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["Achievements"];
for (const achievement of Object.keys(achievements)) {
const unlockedAchievement = achievements[achievement].split("@");
if (unlockedAchievement[0] === "1") {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement[unlockedAchievement.length - 1],
});
}
}
return parsedUnlockedAchievements;
};
const processGoldberg = (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 processDefault = (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 processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
if (achievement === "Steam") continue;
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.State) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true),
});
}
}
return newUnlockedAchievements;
};
const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["ACHIEVEMENTS"];
if (!achievements) return [];
for (const achievement of Object.keys(achievements)) {
const unlockedAchievement = achievements[achievement];
const unlockTime = Number(
unlockedAchievement.slice(1, -1).replace("unlocked = true, time = ", "")
);
if (!isNaN(unlockTime)) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockTime,
});
}
}
return newUnlockedAchievements;
};

View File

@ -0,0 +1,105 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import {
findAllAchievementFiles,
findAchievementFiles,
} from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types";
import { getGameAchievementData } from "./get-game-achievement-data";
export const updateAllLocalUnlockedAchievements = async () => {
const gameAchievementFilesMap = await findAllAchievementFiles();
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;
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 parsedAchievements = await parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
console.log("Parsed for", game.title, parsedAchievements);
if (parsedAchievements.length) {
unlockedAchievements.push(...parsedAchievements);
}
}
mergeAchievements(objectId, "steam", unlockedAchievements, false);
}
};
export const updateLocalUnlockedAchivements = async (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 = await findAchievementFiles(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.length) {
unlockedAchievements.push(...localAchievementFile);
}
}
mergeAchievements(objectId, "steam", unlockedAchievements, false);
};

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,60 +88,66 @@ export class HydraApi {
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
});
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
const data = Array.isArray(request.data)
? request.data
: omit(request.data, ["refreshToken"]);
logger.log(request.method, request.url, request.params, data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
const data = Array.isArray(response.data)
? response.data
: omit(response.data, ["username", "accessToken", "refreshToken"]);
logger.log(
response.status,
response.config.method,
response.config.url,
data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
if (this.ADD_LOG_INTERCEPTOR) {
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
const data = Array.isArray(request.data)
? request.data
: omit(request.data, ["refreshToken"]);
logger.log(request.method, request.url, request.params, data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
const data = Array.isArray(response.data)
? response.data
: omit(response.data, ["username", "accessToken", "refreshToken"]);
logger.log(
response.status,
response.config.method,
response.config.url,
data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error(
"Response",
error.response.status,
error.response.data
);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
logger.error(" ----- END RESPONSE ERROR -------");
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

@ -29,3 +29,4 @@ log.initialize();
export const pythonInstanceLogger = log.scope("python-instance");
export const logger = log.scope("main");
export const achievementsLogger = log.scope("achievements");

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,37 @@ 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: false,
skipTaskbar: true,
frame: false,
width: 350,
height: 104,
x: 0,
y: 0,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.notificationWindow.setIgnoreMouseEvents(true);
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 +218,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"),

View File

@ -9,7 +9,7 @@
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
/>
</head>
<body style="background-color: #1c1c1c">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@ -39,7 +39,6 @@ globalStyle("body", {
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
background: vars.color.background,
color: vars.color.body,
margin: "0",
});

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";
@ -65,6 +66,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 { Achievement } 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={Achievement} />
</Routes>
</HashRouter>
</RepacksContextProvider>

View File

@ -0,0 +1,44 @@
import { recipe } from "@vanilla-extract/recipes";
import { vars } from "../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
const animationIn = keyframes({
"0%": { transform: `translateY(-240px)` },
"100%": { transform: "translateY(0)" },
});
const animationOut = keyframes({
"0%": { transform: `translateY(0)` },
"100%": { transform: "translateY(-240px)" },
});
export const container = recipe({
base: {
marginTop: "24px",
marginLeft: "24px",
animationDuration: "1.0s",
height: "60px",
display: "flex",
},
variants: {
closing: {
true: {
animationName: animationOut,
transform: "translateY(-240px)",
},
false: {
animationName: animationIn,
transform: "translateY(0)",
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
background: vars.color.background,
paddingRight: "8px",
});

View File

@ -0,0 +1,117 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import * as styles from "./achievement.css";
interface AchievementInfo {
displayName: string;
iconUrl: string;
}
const NOTIFICATION_TIMEOUT = 4000;
export function Achievement() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const visibleAnimation = useRef(-1);
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 || !achievements.length) return;
setAchievements((ach) => ach.concat(achievements));
audio.play();
}
);
return () => {
unsubscribe();
};
}, [audio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 1000) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
}
}
);
}, []);
useEffect(() => {
if (hasAchievementsPending) {
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
setAchievements((ach) => ach.slice(1));
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
} else {
startAnimateClosing();
}
}, [hasAchievementsPending]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
if (!isVisible || !currentAchievement) return null;
return (
<div className={styles.container({ closing: isClosing })}>
<div className={styles.content}>
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
style={{ flex: 1, width: "60px" }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{currentAchievement.displayName}</p>
</div>
</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,16 @@ export enum SteamContentDescriptor {
FrequentNudityOrSexualContent = 4,
GeneralMatureContent = 5,
}
export enum Cracker {
codex = "CODEX",
rune = "RUNE",
onlineFix = "OnlineFix",
goldberg = "Goldberg",
userstats = "user_stats",
rld = "RLD!",
empress = "EMPRESS",
skidrow = "SKIDROW",
creamAPI = "CreamAPI",
smartSteamEmu = "SmartSteamEmu",
}

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;
};
@ -268,6 +278,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;

3570
yarn.lock

File diff suppressed because it is too large Load Diff