mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-01-23 13:34:54 +03:00
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:
commit
ef4844b8c0
@ -46,7 +46,7 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"better-sqlite3": "^11.2.1",
|
"better-sqlite3": "^11.3.0",
|
||||||
"check-disk-space": "^3.4.0",
|
"check-disk-space": "^3.4.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
@ -54,8 +54,8 @@
|
|||||||
"create-desktop-shortcuts": "^1.11.0",
|
"create-desktop-shortcuts": "^1.11.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.0.8",
|
||||||
"electron-log": "^5.1.4",
|
"electron-log": "^5.2.0",
|
||||||
"electron-updater": "^6.1.8",
|
"electron-updater": "^6.3.4",
|
||||||
"fetch-cookie": "^3.0.1",
|
"fetch-cookie": "^3.0.1",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.11.2",
|
||||||
@ -104,7 +104,7 @@
|
|||||||
"@vanilla-extract/vite-plugin": "^4.0.7",
|
"@vanilla-extract/vite-plugin": "^4.0.7",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"electron": "^30.3.0",
|
"electron": "^30.3.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^25.1.6",
|
||||||
"electron-vite": "^2.0.0",
|
"electron-vite": "^2.0.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||||
|
BIN
resources/achievement-sound.mp3
Normal file
BIN
resources/achievement-sound.mp3
Normal file
Binary file not shown.
@ -240,7 +240,9 @@
|
|||||||
"repack_count_one": "{{count}} repack added",
|
"repack_count_one": "{{count}} repack added",
|
||||||
"repack_count_other": "{{count}} repacks added",
|
"repack_count_other": "{{count}} repacks added",
|
||||||
"new_update_available": "Version {{version}} available",
|
"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": {
|
"system_tray": {
|
||||||
"open": "Open Hydra",
|
"open": "Open Hydra",
|
||||||
@ -327,5 +329,8 @@
|
|||||||
"report_reason_other": "Other",
|
"report_reason_other": "Other",
|
||||||
"profile_reported": "Profile reported",
|
"profile_reported": "Profile reported",
|
||||||
"your_friend_code": "Your friend code:"
|
"your_friend_code": "Your friend code:"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Achievement unlocked"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -331,5 +331,8 @@
|
|||||||
"report_reason_other": "Outro",
|
"report_reason_other": "Outro",
|
||||||
"profile_reported": "Perfil reportado",
|
"profile_reported": "Perfil reportado",
|
||||||
"your_friend_code": "Seu código de amigo:"
|
"your_friend_code": "Seu código de amigo:"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Conquista desbloqueada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -277,5 +277,8 @@
|
|||||||
"friend_code_copied": "Código de amigo copiado",
|
"friend_code_copied": "Código de amigo copiado",
|
||||||
"image_process_failure": "Falha ao processar a imagem",
|
"image_process_failure": "Falha ao processar a imagem",
|
||||||
"your_friend_code": "Seu código de amigo:"
|
"your_friend_code": "Seu código de amigo:"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Conquista desbloqueada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,12 @@ import path from "node:path";
|
|||||||
export const defaultDownloadsPath = app.getPath("downloads");
|
export const defaultDownloadsPath = app.getPath("downloads");
|
||||||
|
|
||||||
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
|
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");
|
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
import { databasePath } from "./constants";
|
import { databasePath } from "./constants";
|
||||||
@ -21,6 +22,7 @@ export const dataSource = new DataSource({
|
|||||||
DownloadSource,
|
DownloadSource,
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
],
|
],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
database: databasePath,
|
database: databasePath,
|
||||||
|
19
src/main/entity/game-achievements.entity.ts
Normal file
19
src/main/entity/game-achievements.entity.ts
Normal 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;
|
||||||
|
}
|
@ -2,6 +2,8 @@ export * from "./game.entity";
|
|||||||
export * from "./repack.entity";
|
export * from "./repack.entity";
|
||||||
export * from "./user-preferences.entity";
|
export * from "./user-preferences.entity";
|
||||||
export * from "./game-shop-cache.entity";
|
export * from "./game-shop-cache.entity";
|
||||||
|
export * from "./game.entity";
|
||||||
|
export * from "./game-achievements.entity";
|
||||||
export * from "./download-source.entity";
|
export * from "./download-source.entity";
|
||||||
export * from "./download-queue.entity";
|
export * from "./download-queue.entity";
|
||||||
export * from "./user-auth";
|
export * from "./user-auth";
|
||||||
|
94
src/main/events/catalogue/get-game-achievements.ts
Normal file
94
src/main/events/catalogue/get-game-achievements.ts
Normal 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);
|
@ -9,13 +9,10 @@ const getGameStats = async (
|
|||||||
objectId: string,
|
objectId: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams({
|
|
||||||
objectId,
|
|
||||||
shop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await HydraApi.get<GameStats>(
|
const response = await HydraApi.get<GameStats>(
|
||||||
`/games/stats?${params.toString()}`
|
`/games/stats`,
|
||||||
|
{ objectId, shop },
|
||||||
|
{ needsAuth: false }
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,7 @@ import "./catalogue/get-random-game";
|
|||||||
import "./catalogue/search-games";
|
import "./catalogue/search-games";
|
||||||
import "./catalogue/get-game-stats";
|
import "./catalogue/get-game-stats";
|
||||||
import "./catalogue/get-trending-games";
|
import "./catalogue/get-trending-games";
|
||||||
|
import "./catalogue/get-game-achievements";
|
||||||
import "./hardware/get-disk-free-space";
|
import "./hardware/get-disk-free-space";
|
||||||
import "./library/add-game-to-library";
|
import "./library/add-game-to-library";
|
||||||
import "./library/create-game-shortcut";
|
import "./library/create-game-shortcut";
|
||||||
|
@ -7,6 +7,7 @@ import type { GameShop } from "@types";
|
|||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { createGame } from "@main/services/library-sync";
|
import { createGame } from "@main/services/library-sync";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
|
||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -43,6 +44,8 @@ const addGameToLibrary = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLocalUnlockedAchivements(true, objectID);
|
||||||
|
|
||||||
const game = await gameRepository.findOne({ where: { objectID } });
|
const game = await gameRepository.findOne({ where: { objectID } });
|
||||||
|
|
||||||
createGame(game!).catch(() => {});
|
createGame(game!).catch(() => {});
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
|
import { UserNotLoggedInError } from "@shared";
|
||||||
import { FriendRequestSync } from "@types";
|
import { FriendRequestSync } from "@types";
|
||||||
|
|
||||||
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
|
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);
|
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
|
import { UserNotLoggedInError } from "@shared";
|
||||||
import { UserBlocks } from "@types";
|
import { UserBlocks } from "@types";
|
||||||
|
|
||||||
export const getBlockedUsers = async (
|
export const getBlockedUsers = async (
|
||||||
@ -7,7 +8,12 @@ export const getBlockedUsers = async (
|
|||||||
take: number,
|
take: number,
|
||||||
skip: number
|
skip: number
|
||||||
): Promise<UserBlocks> => {
|
): 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);
|
registerEvent("getBlockedUsers", getBlockedUsers);
|
||||||
|
@ -102,6 +102,7 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowManager.createMainWindow();
|
WindowManager.createMainWindow();
|
||||||
|
WindowManager.createNotificationWindow();
|
||||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
|
|||||||
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
||||||
|
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
|
||||||
|
|
||||||
export type HydraMigration = Knex.Migration & { name: string };
|
export type HydraMigration = Knex.Migration & { name: string };
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
|||||||
UpdateUserLanguage,
|
UpdateUserLanguage,
|
||||||
EnsureRepackUris,
|
EnsureRepackUris,
|
||||||
FixMissingColumns,
|
FixMissingColumns,
|
||||||
|
CreateGameAchievement,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
getMigrationName(migration: HydraMigration): string {
|
getMigrationName(migration: HydraMigration): string {
|
||||||
|
@ -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");
|
||||||
|
},
|
||||||
|
};
|
@ -7,6 +7,7 @@ import {
|
|||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
@ -24,3 +25,6 @@ export const downloadSourceRepository =
|
|||||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||||
|
|
||||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||||
|
|
||||||
|
export const gameAchievementRepository =
|
||||||
|
dataSource.getRepository(GameAchievement);
|
||||||
|
14
src/main/services/achievements/achievement-watcher.ts
Normal file
14
src/main/services/achievements/achievement-watcher.ts
Normal 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);
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
86
src/main/services/achievements/game-achievements-observer.ts
Normal file
86
src/main/services/achievements/game-achievements-observer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
17
src/main/services/achievements/get-game-achievement-data.ts
Normal file
17
src/main/services/achievements/get-game-achievement-data.ts
Normal 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",
|
||||||
|
});
|
||||||
|
};
|
116
src/main/services/achievements/merge-achievements.ts
Normal file
116
src/main/services/achievements/merge-achievements.ts
Normal 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);
|
||||||
|
};
|
103
src/main/services/achievements/parse-achievement-file.ts
Normal file
103
src/main/services/achievements/parse-achievement-file.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@ -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
|
||||||
|
);
|
||||||
|
};
|
@ -17,6 +17,7 @@ export class HydraApi {
|
|||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
|
|
||||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
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;
|
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||||
|
|
||||||
@ -87,60 +88,66 @@ export class HydraApi {
|
|||||||
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.instance.interceptors.request.use(
|
if (this.ADD_LOG_INTERCEPTOR) {
|
||||||
(request) => {
|
this.instance.interceptors.request.use(
|
||||||
logger.log(" ---- REQUEST -----");
|
(request) => {
|
||||||
const data = Array.isArray(request.data)
|
logger.log(" ---- REQUEST -----");
|
||||||
? request.data
|
const data = Array.isArray(request.data)
|
||||||
: omit(request.data, ["refreshToken"]);
|
? request.data
|
||||||
logger.log(request.method, request.url, request.params, data);
|
: omit(request.data, ["refreshToken"]);
|
||||||
return request;
|
logger.log(request.method, request.url, request.params, data);
|
||||||
},
|
return request;
|
||||||
(error) => {
|
},
|
||||||
logger.error("request error", error);
|
(error) => {
|
||||||
return Promise.reject(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);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
logger.error(" ----- END RESPONSE ERROR -------");
|
this.instance.interceptors.response.use(
|
||||||
return Promise.reject(error);
|
(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({
|
const userAuth = await userAuthRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
|
@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
|
|||||||
import { HydraApi } from "../hydra-api";
|
import { HydraApi } from "../hydra-api";
|
||||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||||
import { WindowManager } from "../window-manager";
|
import { WindowManager } from "../window-manager";
|
||||||
|
import { updateAllLocalUnlockedAchievements } from "../achievements/update-local-unlocked-achivements";
|
||||||
|
|
||||||
export const uploadGamesBatch = async () => {
|
export const uploadGamesBatch = async () => {
|
||||||
const games = await gameRepository.find({
|
const games = await gameRepository.find({
|
||||||
@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
|
|||||||
|
|
||||||
await mergeWithRemoteGames();
|
await mergeWithRemoteGames();
|
||||||
|
|
||||||
|
await updateAllLocalUnlockedAchievements();
|
||||||
|
|
||||||
if (WindowManager.mainWindow)
|
if (WindowManager.mainWindow)
|
||||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { sleep } from "@main/helpers";
|
import { sleep } from "@main/helpers";
|
||||||
import { DownloadManager } from "./download";
|
import { DownloadManager } from "./download";
|
||||||
import { watchProcesses } from "./process-watcher";
|
import { watchProcesses } from "./process-watcher";
|
||||||
|
import { watchAchievements } from "./achievements/achievement-watcher";
|
||||||
|
|
||||||
export const startMainLoop = async () => {
|
export const startMainLoop = async () => {
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
@ -8,6 +9,7 @@ export const startMainLoop = async () => {
|
|||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
watchProcesses(),
|
watchProcesses(),
|
||||||
DownloadManager.watchDownloads(),
|
DownloadManager.watchDownloads(),
|
||||||
|
watchAchievements(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
@ -25,12 +25,12 @@ export const watchProcesses = async () => {
|
|||||||
if (games.length === 0) return;
|
if (games.length === 0) return;
|
||||||
const processes = await PythonInstance.getProcessList();
|
const processes = await PythonInstance.getProcessList();
|
||||||
|
|
||||||
|
const processSet = new Set(processes.map((process) => process.exe));
|
||||||
|
|
||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
const executablePath = game.executablePath!;
|
const executablePath = game.executablePath!;
|
||||||
|
|
||||||
const gameProcess = processes.find((runningProcess) => {
|
const gameProcess = processSet.has(executablePath);
|
||||||
return executablePath == runningProcess.exe;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (gameProcess) {
|
if (gameProcess) {
|
||||||
if (gamesPlaytime.has(game.id)) {
|
if (gamesPlaytime.has(game.id)) {
|
||||||
|
@ -19,8 +19,9 @@ import { HydraApi } from "./hydra-api";
|
|||||||
|
|
||||||
export class WindowManager {
|
export class WindowManager {
|
||||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
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.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
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() {
|
public static createMainWindow() {
|
||||||
if (this.mainWindow) return;
|
if (this.mainWindow) return;
|
||||||
|
|
||||||
@ -108,7 +124,7 @@ export class WindowManager {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.loadURL();
|
this.loadMainWindowURL();
|
||||||
this.mainWindow.removeMenu();
|
this.mainWindow.removeMenu();
|
||||||
|
|
||||||
this.mainWindow.on("ready-to-show", () => {
|
this.mainWindow.on("ready-to-show", () => {
|
||||||
@ -125,9 +141,36 @@ export class WindowManager {
|
|||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
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() {
|
public static openAuthWindow() {
|
||||||
if (this.mainWindow) {
|
if (this.mainWindow) {
|
||||||
const authWindow = new BrowserWindow({
|
const authWindow = new BrowserWindow({
|
||||||
@ -174,7 +217,7 @@ export class WindowManager {
|
|||||||
|
|
||||||
public static redirect(hash: string) {
|
public static redirect(hash: string) {
|
||||||
if (!this.mainWindow) this.createMainWindow();
|
if (!this.mainWindow) this.createMainWindow();
|
||||||
this.loadURL(hash);
|
this.loadMainWindowURL(hash);
|
||||||
|
|
||||||
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
||||||
this.mainWindow?.focus();
|
this.mainWindow?.focus();
|
||||||
|
@ -50,6 +50,25 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
getGameStats: (objectId: string, shop: GameShop) =>
|
getGameStats: (objectId: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
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 */
|
/* User preferences */
|
||||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||||
|
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
Binary file not shown.
@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Game,
|
Game,
|
||||||
|
GameAchievement,
|
||||||
GameRepack,
|
GameRepack,
|
||||||
GameShop,
|
GameShop,
|
||||||
GameStats,
|
GameStats,
|
||||||
@ -36,6 +37,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||||||
showRepacksModal: false,
|
showRepacksModal: false,
|
||||||
showGameOptionsModal: false,
|
showGameOptionsModal: false,
|
||||||
stats: null,
|
stats: null,
|
||||||
|
achievements: [],
|
||||||
hasNSFWContentBlocked: false,
|
hasNSFWContentBlocked: false,
|
||||||
setGameColor: () => {},
|
setGameColor: () => {},
|
||||||
selectGameExecutable: async () => null,
|
selectGameExecutable: async () => null,
|
||||||
@ -62,6 +64,7 @@ export function GameDetailsContextProvider({
|
|||||||
shop,
|
shop,
|
||||||
}: GameDetailsContextProps) {
|
}: GameDetailsContextProps) {
|
||||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||||
|
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
const [game, setGame] = useState<Game | null>(null);
|
||||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||||
|
|
||||||
@ -133,6 +136,15 @@ export function GameDetailsContextProvider({
|
|||||||
setStats(result);
|
setStats(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.electron
|
||||||
|
.getGameAchievements(objectId!, shop as GameShop)
|
||||||
|
.then((achievements) => {
|
||||||
|
setAchievements(achievements);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// TODO: handle user not logged in error
|
||||||
|
});
|
||||||
|
|
||||||
updateGame();
|
updateGame();
|
||||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||||
|
|
||||||
@ -141,6 +153,7 @@ export function GameDetailsContextProvider({
|
|||||||
setGame(null);
|
setGame(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setisGameRunning(false);
|
setisGameRunning(false);
|
||||||
|
setAchievements([]);
|
||||||
dispatch(setHeaderTitle(gameTitle));
|
dispatch(setHeaderTitle(gameTitle));
|
||||||
}, [objectId, gameTitle, dispatch]);
|
}, [objectId, gameTitle, dispatch]);
|
||||||
|
|
||||||
@ -161,6 +174,23 @@ export function GameDetailsContextProvider({
|
|||||||
};
|
};
|
||||||
}, [game?.id, isGameRunning, updateGame]);
|
}, [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 () => {
|
const getDownloadsPath = async () => {
|
||||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||||
return window.electron.getDefaultDownloadsPath();
|
return window.electron.getDefaultDownloadsPath();
|
||||||
@ -204,6 +234,7 @@ export function GameDetailsContextProvider({
|
|||||||
showGameOptionsModal,
|
showGameOptionsModal,
|
||||||
showRepacksModal,
|
showRepacksModal,
|
||||||
stats,
|
stats,
|
||||||
|
achievements,
|
||||||
hasNSFWContentBlocked,
|
hasNSFWContentBlocked,
|
||||||
setHasNSFWContentBlocked,
|
setHasNSFWContentBlocked,
|
||||||
setGameColor,
|
setGameColor,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Game,
|
Game,
|
||||||
|
GameAchievement,
|
||||||
GameRepack,
|
GameRepack,
|
||||||
GameShop,
|
GameShop,
|
||||||
GameStats,
|
GameStats,
|
||||||
@ -19,6 +20,7 @@ export interface GameDetailsContext {
|
|||||||
showRepacksModal: boolean;
|
showRepacksModal: boolean;
|
||||||
showGameOptionsModal: boolean;
|
showGameOptionsModal: boolean;
|
||||||
stats: GameStats | null;
|
stats: GameStats | null;
|
||||||
|
achievements: GameAchievement[];
|
||||||
hasNSFWContentBlocked: boolean;
|
hasNSFWContentBlocked: boolean;
|
||||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||||
selectGameExecutable: () => Promise<string | null>;
|
selectGameExecutable: () => Promise<string | null>;
|
||||||
|
12
src/renderer/src/declaration.d.ts
vendored
12
src/renderer/src/declaration.d.ts
vendored
@ -25,6 +25,7 @@ import type {
|
|||||||
UserStats,
|
UserStats,
|
||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
GameAchievement,
|
||||||
GameArtifact,
|
GameArtifact,
|
||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
@ -68,6 +69,17 @@ declare global {
|
|||||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
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 */
|
/* Library */
|
||||||
addGameToLibrary: (
|
addGameToLibrary: (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { formatDistance, subMilliseconds } from "date-fns";
|
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||||
import type { FormatDistanceOptions } from "date-fns";
|
import type { FormatDistanceOptions } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ptBR,
|
ptBR,
|
||||||
@ -67,5 +67,13 @@ export function useDate() {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
format: (timestamp: number): string => {
|
||||||
|
const locale = getDateLocale();
|
||||||
|
return format(
|
||||||
|
timestamp,
|
||||||
|
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ import {
|
|||||||
import { store } from "./store";
|
import { store } from "./store";
|
||||||
|
|
||||||
import resources from "@locales";
|
import resources from "@locales";
|
||||||
|
import { Achievemnt } from "./pages/achievement/achievement";
|
||||||
|
|
||||||
import "./workers";
|
import "./workers";
|
||||||
import { RepacksContextProvider } from "./context";
|
import { RepacksContextProvider } from "./context";
|
||||||
@ -69,6 +70,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" Component={Settings} />
|
<Route path="/settings" Component={Settings} />
|
||||||
<Route path="/profile/:userId" Component={Profile} />
|
<Route path="/profile/:userId" Component={Profile} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/achievement-notification" Component={Achievemnt} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</RepacksContextProvider>
|
</RepacksContextProvider>
|
||||||
|
64
src/renderer/src/pages/achievement/achievement.tsx
Normal file
64
src/renderer/src/pages/achievement/achievement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -5,8 +5,9 @@ import { Button } from "@renderer/components";
|
|||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
import { useFormat } from "@renderer/hooks";
|
import { useDate, useFormat } from "@renderer/hooks";
|
||||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||||
|
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [_howLongToBeat, _setHowLongToBeat] = useState<{
|
const [_howLongToBeat, _setHowLongToBeat] = useState<{
|
||||||
@ -17,9 +18,11 @@ export function Sidebar() {
|
|||||||
const [activeRequirement, setActiveRequirement] =
|
const [activeRequirement, setActiveRequirement] =
|
||||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
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 { t } = useTranslation("game_details");
|
||||||
|
const { format } = useDate();
|
||||||
|
|
||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
@ -45,6 +48,47 @@ export function Sidebar() {
|
|||||||
isLoading={howLongToBeat.isLoading}
|
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 && (
|
{stats && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
@ -23,3 +23,11 @@ export enum SteamContentDescriptor {
|
|||||||
FrequentNudityOrSexualContent = 4,
|
FrequentNudityOrSexualContent = 4,
|
||||||
GeneralMatureContent = 5,
|
GeneralMatureContent = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Cracker {
|
||||||
|
codex = "CODEX",
|
||||||
|
rune = "RUNE",
|
||||||
|
onlineFix = "OnlineFix",
|
||||||
|
goldberg = "Goldberg",
|
||||||
|
generic = "Generic",
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { DownloadSourceStatus, Downloader } from "@shared";
|
import type { Cracker, DownloadSourceStatus, Downloader } from "@shared";
|
||||||
import type { SteamAppDetails } from "./steam.types";
|
import type { SteamAppDetails } from "./steam.types";
|
||||||
|
|
||||||
export type GameStatus =
|
export type GameStatus =
|
||||||
@ -28,6 +28,16 @@ export interface GameRepack {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameAchievement {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
unlocked: boolean;
|
||||||
|
unlockTime: number | null;
|
||||||
|
icon: string;
|
||||||
|
icongray: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ShopDetails = SteamAppDetails & {
|
export type ShopDetails = SteamAppDetails & {
|
||||||
objectID: string;
|
objectID: string;
|
||||||
};
|
};
|
||||||
@ -266,6 +276,20 @@ export interface UserStats {
|
|||||||
friendsCount: number;
|
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 {
|
export interface GameArtifact {
|
||||||
id: string;
|
id: string;
|
||||||
artifactLengthInBytes: number;
|
artifactLengthInBytes: number;
|
||||||
|
Loading…
Reference in New Issue
Block a user