Merge branch 'feature/game-achievements' of github.com:hydralauncher/hydra into feature/cloud-sync

This commit is contained in:
Chubby Granny Chaser
2024-10-16 10:46:35 +01:00
48 changed files with 1597 additions and 376 deletions

View File

@@ -8,6 +8,7 @@ import {
UserPreferences,
UserAuth,
GameAchievement,
UserSubscription,
} from "@main/entity";
import { databasePath } from "./constants";
@@ -17,11 +18,12 @@ export const dataSource = new DataSource({
entities: [
Game,
Repack,
UserAuth,
UserPreferences,
UserSubscription,
GameShopCache,
DownloadSource,
DownloadQueue,
UserAuth,
GameAchievement,
],
synchronize: false,

View File

@@ -1,9 +1,10 @@
export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-auth.entity";
export * from "./user-preferences.entity";
export * from "./user-subscription.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

@@ -4,7 +4,9 @@ import {
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
} from "typeorm";
import { UserSubscription } from "./user-subscription.entity";
@Entity("user_auth")
export class UserAuth {
@@ -29,6 +31,9 @@ export class UserAuth {
@Column("int", { default: 0 })
tokenExpirationTimestamp: number;
@OneToOne("UserSubscription", "user")
subscription: UserSubscription | null;
@CreateDateColumn()
createdAt: Date;

View File

@@ -26,6 +26,9 @@ export class UserPreferences {
@Column("boolean", { default: false })
repackUpdatesNotificationsEnabled: boolean;
@Column("boolean", { default: true })
achievementNotificationsEnabled: boolean;
@Column("boolean", { default: false })
preferQuitInsteadOfHiding: boolean;

View File

@@ -0,0 +1,42 @@
import type { SubscriptionStatus } from "@types";
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import { UserAuth } from "./user-auth.entity";
@Entity("user_subscription")
export class UserSubscription {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { default: "" })
subscriptionId: string;
@OneToOne("UserAuth", "subscription")
@JoinColumn()
user: UserAuth;
@Column("text", { default: "" })
status: SubscriptionStatus;
@Column("text", { default: "" })
planId: string;
@Column("text", { default: "" })
planName: string;
@Column("datetime", { nullable: true })
expiresAt: Date | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -1,57 +1,32 @@
import type { GameAchievement, GameShop, UnlockedAchievement } from "@types";
import type {
AchievementData,
GameShop,
RemoteUnlockedAchievement,
UnlockedAchievement,
UserAchievement,
} from "@types";
import { registerEvent } from "../register-event";
import {
gameAchievementRepository,
userAuthRepository,
userPreferencesRepository,
} from "@main/repository";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { HydraApi } from "@main/services";
const getAchievements = async (
shop: string,
objectId: string,
userId?: string
) => {
const userAuth = await userAuthRepository.findOne({ where: { userId } });
const getAchievementLocalUser = async (shop: string, objectId: string) => {
const cachedAchievements = await gameAchievementRepository.findOne({
where: { objectId, shop },
});
const achievementsData = cachedAchievements?.achievements
? JSON.parse(cachedAchievements.achievements)
: await getGameAchievementData(objectId, shop);
const achievementsData = await getGameAchievementData(objectId, shop);
if (!userId || userAuth) {
const unlockedAchievements = JSON.parse(
cachedAchievements?.unlockedAchievements || "[]"
) as UnlockedAchievement[];
return { achievementsData, unlockedAchievements };
}
const unlockedAchievements = await HydraApi.get<UnlockedAchievement[]>(
`/users/${userId}/games/achievements`,
{ shop, objectId, language: "en" }
);
return { achievementsData, unlockedAchievements };
};
export const getGameAchievements = async (
objectId: string,
shop: GameShop,
userId?: string
): Promise<GameAchievement[]> => {
const { achievementsData, unlockedAchievements } = await getAchievements(
shop,
objectId,
userId
);
const unlockedAchievements = JSON.parse(
cachedAchievements?.unlockedAchievements || "[]"
) as UnlockedAchievement[];
return achievementsData
.map((achievementData) => {
const unlockedAchiement = unlockedAchievements.find(
const unlockedAchiementData = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
@@ -60,29 +35,112 @@ export const getGameAchievements = async (
}
);
if (unlockedAchiement) {
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
if (unlockedAchiementData) {
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchiement.unlockTime,
unlockTime: unlockedAchiementData.unlockTime,
};
}
return { ...achievementData, unlocked: false, unlockTime: null };
return {
...achievementData,
unlocked: false,
unlockTime: null,
icon: icongray,
} as UserAchievement;
})
.sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1;
if (!a.unlocked && b.unlocked) return 1;
return b.unlockTime - a.unlockTime;
if (a.unlocked && b.unlocked) {
return b.unlockTime! - a.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden);
});
};
const getAchievementsRemoteUser = async (
shop: string,
objectId: string,
userId: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const achievementsData: AchievementData[] = await getGameAchievementData(
objectId,
shop
);
const unlockedAchievements = await HydraApi.get<RemoteUnlockedAchievement[]>(
`/users/${userId}/games/achievements`,
{ shop, objectId, language: userPreferences?.language || "en" }
);
return achievementsData
.map((achievementData) => {
const unlockedAchiementData = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
if (unlockedAchiementData) {
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchiementData.unlockTime,
};
}
return {
...achievementData,
unlocked: false,
unlockTime: null,
icon: icongray,
} as UserAchievement;
})
.sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1;
if (!a.unlocked && b.unlocked) return 1;
if (a.unlocked && b.unlocked) {
return b.unlockTime! - a.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden);
});
};
export const getGameAchievements = async (
objectId: string,
shop: GameShop,
userId?: string
): Promise<UserAchievement[]> => {
if (!userId) {
return getAchievementLocalUser(shop, objectId);
}
return getAchievementsRemoteUser(shop, objectId, userId);
};
const getGameAchievementsEvent = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
userId?: string
): Promise<GameAchievement[]> => {
): Promise<UserAchievement[]> => {
return getGameAchievements(objectId, shop, userId);
};

View File

@@ -50,7 +50,8 @@ const openGameInstaller = async (
}
if (fs.lstatSync(gamePath).isFile()) {
return executeGameInstaller(gamePath);
shell.showItemInFolder(gamePath);
return true;
}
const setupPath = path.join(gamePath, "setup.exe");

View File

@@ -1,15 +1,18 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
import { ProfileVisibility, UserDetails } from "@types";
import { userAuthRepository } from "@main/repository";
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import { UserNotLoggedInError } from "@shared";
const getMe = async (
_event: Electron.IpcMainInvokeEvent
): Promise<UserDetails | null> => {
return HydraApi.get<UserDetails>(`/profile/me`)
.then(async (me) => {
.then((me) => {
userAuthRepository.upsert(
{
id: 1,
@@ -20,6 +23,23 @@ const getMe = async (
["id"]
);
if (me.subscription) {
userSubscriptionRepository.upsert(
{
id: 1,
subscriptionId: me.subscription?.id || "",
status: me.subscription?.status || "",
planId: me.subscription?.plan.id || "",
planName: me.subscription?.plan.name || "",
expiresAt: me.subscription?.expiresAt || null,
user: { id: 1 },
},
["id"]
);
} else {
userSubscriptionRepository.delete({ id: 1 });
}
Sentry.setUser({ id: me.id, username: me.username });
return me;
@@ -28,7 +48,7 @@ const getMe = async (
if (err instanceof UserNotLoggedInError) {
return null;
}
logger.error("Failed to get logged user", err);
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
if (loggedUser) {
@@ -38,6 +58,17 @@ const getMe = async (
username: "",
bio: "",
profileVisibility: "PUBLIC" as ProfileVisibility,
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.subscriptionId,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.planId,
name: loggedUser.subscription.planName,
},
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
};
}

View File

@@ -7,6 +7,8 @@ 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";
import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference";
import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription";
export type HydraMigration = Knex.Migration & { name: string };
@@ -19,6 +21,8 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
AddAchievementNotificationPreference,
CreateUserSubscription,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddAchievementNotificationPreference: HydraMigration = {
name: "AddAchievementNotificationPreference",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("achievementNotificationsEnabled").defaultTo(true);
});
},
down: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("achievementNotificationsEnabled");
});
},
};

View File

@@ -0,0 +1,27 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateUserSubscription: HydraMigration = {
name: "CreateUserSubscription",
up: async (knex: Knex) => {
return knex.schema.createTable("user_subscription", (table) => {
table.increments("id").primary();
table.string("subscriptionId").defaultTo("");
table
.text("userId")
.notNullable()
.references("user_auth.id")
.onDelete("CASCADE");
table.string("status").defaultTo("");
table.string("planId").defaultTo("");
table.string("planName").defaultTo("");
table.dateTime("expiresAt").nullable();
table.dateTime("createdAt").defaultTo(knex.fn.now());
table.dateTime("updatedAt").defaultTo(knex.fn.now());
});
},
down: async (knex: Knex) => {
return knex.schema.dropTable("user_subscription");
},
};

View File

@@ -3,8 +3,8 @@ import type { Knex } from "knex";
export const MigrationName: HydraMigration = {
name: "MigrationName",
up: async (knex: Knex) => {
await knex.schema.createTable("table_name", (table) => {});
up: (knex: Knex) => {
return knex.schema.createTable("table_name", async (table) => {});
},
down: async (knex: Knex) => {},

View File

@@ -8,6 +8,7 @@ import {
UserPreferences,
UserAuth,
GameAchievement,
UserSubscription,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@@ -26,5 +27,8 @@ export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const userSubscriptionRepository =
dataSource.getRepository(UserSubscription);
export const gameAchievementRepository =
dataSource.getRepository(GameAchievement);

View File

@@ -113,8 +113,8 @@ const compareFile = async (game: Game, file: AchievementFile) => {
logger.log(
"Detected change in file",
file.filePath,
currentStat.mtimeMs,
fileStats.get(file.filePath)
previousStat,
currentStat.mtimeMs
);
await processAchievementFileDiff(game, file);
} catch (err) {

View File

@@ -53,9 +53,13 @@ const getPathFromCracker = (cracker: Cracker) => {
if (cracker === Cracker.onlineFix) {
return [
{
folderPath: path.join(publicDocuments, Cracker.onlineFix),
folderPath: path.join(publicDocuments, "OnlineFix"),
fileLocation: ["Stats", "Achievements.ini"],
},
{
folderPath: path.join(publicDocuments, "OnlineFix"),
fileLocation: ["Achievements.ini"],
},
];
}

View File

@@ -3,6 +3,9 @@ import {
userPreferencesRepository,
} from "@main/repository";
import { HydraApi } from "../hydra-api";
import { AchievementData } from "@types";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
export const getGameAchievementData = async (
objectId: string,
@@ -12,13 +15,13 @@ export const getGameAchievementData = async (
where: { id: 1 },
});
return HydraApi.get("/games/achievements", {
return HydraApi.get<AchievementData[]>("/games/achievements", {
shop,
objectId,
language: userPreferences?.language || "en",
})
.then(async (achievements) => {
await gameAchievementRepository.upsert(
.then((achievements) => {
gameAchievementRepository.upsert(
{
objectId,
shop,
@@ -29,5 +32,17 @@ export const getGameAchievementData = async (
return achievements;
})
.catch(() => []);
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
throw err;
}
logger.error("Failed to get game achievements", err);
return gameAchievementRepository
.findOne({
where: { objectId, shop },
})
.then((gameAchievements) => {
return JSON.parse(gameAchievements?.achievements || "[]");
});
});
};

View File

@@ -1,5 +1,9 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import type { GameShop, UnlockedAchievement } from "@types";
import {
gameAchievementRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import type { AchievementData, GameShop, UnlockedAchievement } from "@types";
import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api";
import { getGameAchievements } from "@main/events/catalogue/get-game-achievements";
@@ -18,11 +22,15 @@ const saveAchievementsOnLocal = async (
},
["objectId", "shop"]
)
.then(async () => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
await getGameAchievements(objectId, shop as GameShop)
);
.then(() => {
return getGameAchievements(objectId, shop as GameShop)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
});
};
@@ -38,16 +46,23 @@ export const mergeAchievements = async (
if (!game) return;
const localGameAchievement = await gameAchievementRepository.findOne({
where: {
objectId,
shop,
},
});
const [localGameAchievement, userPreferences] = await Promise.all([
gameAchievementRepository.findOne({
where: {
objectId,
shop,
},
}),
userPreferencesRepository.findOne({ where: { id: 1 } }),
]);
const achievementsData = JSON.parse(
localGameAchievement?.achievements || "[]"
) as AchievementData[];
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
).filter((achievement) => achievement.name);
).filter((achievement) => achievement.name) as UnlockedAchievement[];
const newAchievements = achievements
.filter((achievement) => {
@@ -64,26 +79,28 @@ export const mergeAchievements = async (
};
});
if (newAchievements.length && publishNotification) {
if (
newAchievements.length &&
publishNotification &&
userPreferences?.achievementNotificationsEnabled
) {
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()
);
}
);
return achievementsData.find((steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
});
})
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement.displayName,
iconUrl: achievement.icon,
displayName: achievement!.displayName,
iconUrl: achievement!.icon,
};
});
@@ -98,10 +115,14 @@ export const mergeAchievements = async (
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (game?.remoteId) {
return HydraApi.put("/profile/games/achievements", {
id: game.remoteId,
achievements: mergedLocalAchievements,
})
return HydraApi.put(
"/profile/games/achievements",
{
id: game.remoteId,
achievements: mergedLocalAchievements,
},
{ needsCloud: true }
)
.then((response) => {
return saveAchievementsOnLocal(
response.objectId,

View File

@@ -73,7 +73,12 @@ export const parseAchievementFile = (
const iniParse = (filePath: string) => {
try {
const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/);
const fileContent = readFileSync(filePath, "utf-8");
const lines =
fileContent.charCodeAt(0) === 0xfeff
? fileContent.slice(1).split(/[\r\n]+/)
: fileContent.split(/[\r\n]+/);
let objectName = "";
const object: Record<string, Record<string, string | number>> = {};
@@ -112,11 +117,21 @@ const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
if (unlockedAchievement?.achieved == "true") {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.timestamp * 1000,
});
} else if (unlockedAchievement?.Achieved == "true") {
const unlockTime = unlockedAchievement.TimeUnlocked;
parsedUnlockedAchievements.push({
name: achievement,
unlockTime:
unlockTime.length === 7
? unlockTime * 1000 * 1000
: unlockTime * 1000,
});
}
}
@@ -129,7 +144,7 @@ const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => {
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
if (unlockedAchievement?.achieved == "true") {
const unlockTime = unlockedAchievement.unlocktime;
parsedUnlockedAchievements.push({
name: achievement,
@@ -207,7 +222,7 @@ const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.Achieved) {
if (unlockedAchievement?.Achieved == "1") {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.UnlockTime * 1000,

View File

@@ -1,16 +1,23 @@
import { userAuthRepository } from "@main/repository";
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import axios, { AxiosError, AxiosInstance } from "axios";
import { WindowManager } from "./window-manager";
import url from "url";
import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
import { logger } from "./logger";
import { UserNotLoggedInError } from "@shared";
import {
UserNotLoggedInError,
UserWithoutCloudSubscriptionError,
} from "@shared";
import { omit } from "lodash-es";
import { appVersion } from "@main/constants";
interface HydraApiOptions {
needsAuth: boolean;
needsAuth?: boolean;
needsCloud?: boolean;
}
export class HydraApi {
@@ -31,6 +38,19 @@ export class HydraApi {
return this.userAuth.authToken !== "";
}
private static async hasCloudSubscription() {
// TODO change this later, this is just a quick test
return userSubscriptionRepository
.findOne({ where: { id: 1 } })
.then((userSubscription) => {
if (userSubscription?.status !== "active") return false;
return (
!userSubscription.expiresAt ||
userSubscription!.expiresAt > new Date()
);
});
}
static async handleExternalAuth(uri: string) {
const { payload } = url.parse(uri, true).query;
@@ -234,15 +254,28 @@ export class HydraApi {
throw err;
};
private static async validateOptions(options?: HydraApiOptions) {
const needsAuth = options?.needsAuth == undefined || options.needsAuth;
const needsCloud = options?.needsCloud === true;
if (needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
if (needsCloud) {
if (!(await this.hasCloudSubscription())) {
throw new UserWithoutCloudSubscriptionError();
}
}
}
static async get<T = any>(
url: string,
params?: any,
options?: HydraApiOptions
) {
if (!options || options.needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
await this.validateOptions(options);
return this.instance
.get<T>(url, { params, ...this.getAxiosConfig() })
@@ -255,10 +288,7 @@ export class HydraApi {
data?: any,
options?: HydraApiOptions
) {
if (!options || options.needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
await this.validateOptions(options);
return this.instance
.post<T>(url, data, this.getAxiosConfig())
@@ -271,10 +301,7 @@ export class HydraApi {
data?: any,
options?: HydraApiOptions
) {
if (!options || options.needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
await this.validateOptions(options);
return this.instance
.put<T>(url, data, this.getAxiosConfig())
@@ -287,10 +314,7 @@ export class HydraApi {
data?: any,
options?: HydraApiOptions
) {
if (!options || options.needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
await this.validateOptions(options);
return this.instance
.patch<T>(url, data, this.getAxiosConfig())
@@ -299,10 +323,7 @@ export class HydraApi {
}
static async delete<T = any>(url: string, options?: HydraApiOptions) {
if (!options || options.needsAuth) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired();
}
await this.validateOptions(options);
return this.instance
.delete<T>(url, this.getAxiosConfig())