feat: adding drive mapping

This commit is contained in:
Chubby Granny Chaser 2024-10-08 03:33:57 +01:00
commit 16cd5b43d8
No known key found for this signature in database
26 changed files with 620 additions and 421 deletions

View File

@ -131,6 +131,7 @@
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
"warning": "Warning:",
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
"achievements": "Achievements {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",

View File

@ -127,6 +127,7 @@
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
"achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Salvamento em nuvem",
"cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
"backups": "Backups",

View File

@ -115,7 +115,8 @@
"download": "Transferir",
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
"achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}"
},
"activation": {
"title": "Ativação",

View File

@ -1,88 +1,74 @@
import type { GameAchievement, GameShop } from "@types";
import type { GameAchievement, GameShop, UnlockedAchievement } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import {
gameAchievementRepository,
gameRepository,
userPreferencesRepository,
userAuthRepository,
} from "@main/repository";
import { UserNotLoggedInError } from "@shared";
import { Game } from "@main/entity";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { HydraApi } from "@main/services";
const getAchievementsDataFromApi = async (
objectId: string,
const getAchievements = async (
shop: string,
game: Game | null
objectId: string,
userId?: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
const userAuth = await userAuthRepository.findOne({ where: { userId } });
const cachedAchievements = await gameAchievementRepository.findOne({
where: { objectId, shop },
});
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"]
);
}
const achievementsData = cachedAchievements?.achievements
? JSON.parse(cachedAchievements.achievements)
: await getGameAchievementData(objectId, shop);
return achievements;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) throw err;
return [];
});
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 };
};
const getGameAchievements = async (
_event: Electron.IpcMainInvokeEvent,
export const getGameAchievements = async (
objectId: string,
shop: GameShop
shop: GameShop,
userId?: string
): Promise<GameAchievement[]> => {
const [game, cachedAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop },
}),
gameAchievementRepository.findOne({ where: { objectId, shop } }),
]);
const { achievementsData, unlockedAchievements } = await getAchievements(
shop,
objectId,
userId
);
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) => {
return achievementsData
.map((achievementData) => {
const unlockedAchiement = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
achievement.name.toUpperCase()
achievementData.name.toUpperCase()
);
}
);
if (unlockedAchiement) {
return {
...achievement,
...achievementData,
unlocked: true,
unlockTime: unlockedAchiement.unlockTime,
};
}
return { ...achievement, unlocked: false, unlockTime: null };
return { ...achievementData, unlocked: false, unlockTime: null };
})
.sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1;
@ -91,4 +77,13 @@ const getGameAchievements = async (
});
};
registerEvent("getGameAchievements", getGameAchievements);
const getGameAchievementsEvent = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
userId?: string
): Promise<GameAchievement[]> => {
return getGameAchievements(objectId, shop, userId);
};
registerEvent("getGameAchievements", getGameAchievementsEvent);

View File

@ -20,11 +20,6 @@ export interface LudusaviBackup {
};
}
const getPathDrive = (key: string) => {
const parts = key.split("/");
return parts[0];
};
const replaceLudusaviBackupWithCurrentUser = (
backupPath: string,
title: string

View File

@ -44,12 +44,12 @@ const addGameToLibrary = async (
});
}
updateLocalUnlockedAchivements(objectId);
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
});
};

View File

@ -30,4 +30,4 @@ export const isPortableVersion = () =>
process.env.PORTABLE_EXECUTABLE_FILE !== null;
export const normalizePath = (str: string) =>
path.normalize(str).replace(/\\/g, "/");
path.posix.normalize(str).replace(/\\/g, "/");

View File

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

View File

@ -1,5 +1,19 @@
import { gameRepository } from "@main/repository";
import { checkAchievementFileChange as searchForAchievements } from "./achievement-file-observer";
import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs";
import {
findAchievementFileInExecutableDirectory,
findAllAchievementFiles,
getAlternativeObjectIds,
} from "./find-achivement-files";
import type { AchievementFile } from "@types";
import { achievementsLogger, logger } from "../logger";
import { Cracker } from "@shared";
const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map();
export const watchAchievements = async () => {
const games = await gameRepository.find({
@ -10,5 +24,100 @@ export const watchAchievements = async () => {
if (games.length === 0) return;
await searchForAchievements(games);
const achievementFiles = findAllAchievementFiles();
for (const game of games) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const gameAchievementFiles = achievementFiles.get(objectId) || [];
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
if (!gameAchievementFiles.length) continue;
console.log(
"Achievements files to observe for:",
game.title,
gameAchievementFiles
);
for (const file of gameAchievementFiles) {
compareFile(game, file);
}
}
}
};
const processAchievementFileDiff = async (
game: Game,
file: AchievementFile
) => {
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
logger.log("Achievements from file", file.filePath, unlockedAchievements);
if (unlockedAchievements.length) {
return mergeAchievements(
game.objectID,
game.shop,
unlockedAchievements,
true
);
}
};
const compareFltFolder = async (game: Game, file: AchievementFile) => {
try {
const currentAchievements = new Set(readdirSync(file.filePath));
const previousAchievements = fltFiles.get(file.filePath);
fltFiles.set(file.filePath, currentAchievements);
if (
!previousAchievements ||
currentAchievements.difference(previousAchievements).size === 0
) {
return;
}
logger.log("Detected change in FLT folder", file.filePath);
await processAchievementFileDiff(game, file);
} catch (err) {
achievementsLogger.error(err);
fltFiles.set(file.filePath, new Set());
}
};
const compareFile = async (game: Game, file: AchievementFile) => {
if (file.type === Cracker.flt) {
await compareFltFolder(game, file);
return;
}
try {
const currentStat = fs.statSync(file.filePath);
const previousStat = fileStats.get(file.filePath);
fileStats.set(file.filePath, currentStat.mtimeMs);
if (!previousStat) {
if (currentStat.mtimeMs) {
await processAchievementFileDiff(game, file);
return;
}
}
if (previousStat === currentStat.mtimeMs) {
return;
}
logger.log(
"Detected change in file",
file.filePath,
currentStat.mtimeMs,
fileStats.get(file.filePath)
);
await processAchievementFileDiff(game, file);
} catch (err) {
fileStats.set(file.filePath, -1);
}
};

View File

@ -24,9 +24,10 @@ const crackers = [
Cracker.skidrow,
Cracker.smartSteamEmu,
Cracker.empress,
Cracker.flt,
];
const getPathFromCracker = async (cracker: Cracker) => {
const getPathFromCracker = (cracker: Cracker) => {
if (cracker === Cracker.codex) {
return [
{
@ -85,6 +86,10 @@ const getPathFromCracker = async (cracker: Cracker) => {
folderPath: path.join(programData, "Steam", "Player"),
fileLocation: ["stats", "achievements.ini"],
},
{
folderPath: path.join(programData, "Steam", "dodi"),
fileLocation: ["stats", "achievements.ini"],
},
];
}
@ -131,7 +136,33 @@ const getPathFromCracker = async (cracker: Cracker) => {
return [
{
folderPath: path.join(appData, "SmartSteamEmu"),
fileLocation: ["User", "Achievements"],
fileLocation: ["User", "Achievements.ini"],
},
];
}
if (cracker === Cracker._3dm) {
return [];
}
if (cracker === Cracker.flt) {
return [
{
folderPath: path.join(appData, "FLT"),
fileLocation: ["stats"],
},
];
}
if (cracker == Cracker.rle) {
return [
{
folderPath: path.join(appData, "RLE"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(appData, "RLE"),
fileLocation: ["Achievements.ini"],
},
];
}
@ -140,20 +171,29 @@ const getPathFromCracker = async (cracker: Cracker) => {
throw new Error(`Cracker ${cracker} not implemented`);
};
export const findAchievementFiles = async (game: Game) => {
export const getAlternativeObjectIds = (objectId: string) => {
// Dishonored
if (objectId === "205100") {
return ["205100", "217980", "31292"];
}
return [objectId];
};
export const findAchievementFiles = (game: Game) => {
const achievementFiles: AchievementFile[] = [];
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of await getPathFromCracker(
cracker
)) {
const filePath = path.join(folderPath, game.objectID, ...fileLocation);
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const filePath = path.join(folderPath, objectId, ...fileLocation);
if (fs.existsSync(filePath)) {
achievementFiles.push({
type: cracker,
filePath,
});
if (fs.existsSync(filePath)) {
achievementFiles.push({
type: cracker,
filePath,
});
}
}
}
}
@ -163,31 +203,40 @@ export const findAchievementFiles = async (game: Game) => {
export const findAchievementFileInExecutableDirectory = (
game: Game
): AchievementFile | null => {
): AchievementFile[] => {
if (!game.executablePath) {
return null;
return [];
}
const steamDataPath = path.join(
game.executablePath,
"..",
"SteamData",
"user_stats.ini"
);
return {
type: Cracker.userstats,
filePath: steamDataPath,
};
return [
{
type: Cracker.userstats,
filePath: path.join(
game.executablePath,
"..",
"SteamData",
"user_stats.ini"
),
},
{
type: Cracker._3dm,
filePath: path.join(
game.executablePath,
"..",
"3DMGAME",
"Player",
"stats",
"achievements.ini"
),
},
];
};
export const findAllAchievementFiles = async () => {
export const findAllAchievementFiles = () => {
const gameAchievementFiles = new Map<string, AchievementFile[]>();
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of await getPathFromCracker(
cracker
)) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
if (!fs.existsSync(folderPath)) {
continue;
}

View File

@ -1,4 +1,7 @@
import { userPreferencesRepository } from "@main/repository";
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { HydraApi } from "../hydra-api";
export const getGameAchievementData = async (
@ -13,5 +16,18 @@ export const getGameAchievementData = async (
shop,
objectId,
language: userPreferences?.language || "en",
});
})
.then(async (achievements) => {
await gameAchievementRepository.upsert(
{
objectId,
shop,
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
return achievements;
})
.catch(() => []);
};

View File

@ -2,6 +2,7 @@ import { gameAchievementRepository, gameRepository } from "@main/repository";
import type { GameShop, UnlockedAchievement } from "@types";
import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api";
import { getGameAchievements } from "@main/events/catalogue/get-game-achievements";
const saveAchievementsOnLocal = async (
objectId: string,
@ -17,11 +18,10 @@ const saveAchievementsOnLocal = async (
},
["objectId", "shop"]
)
.then(() => {
.then(async () => {
WindowManager.mainWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop
`on-update-achievements-${objectId}-${shop}`,
await getGameAchievements(objectId, shop as GameShop)
);
});
};
@ -47,7 +47,7 @@ export const mergeAchievements = async (
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
);
).filter((achievement) => achievement.name);
const newAchievements = achievements
.filter((achievement) => {
@ -60,7 +60,7 @@ export const mergeAchievements = async (
.map((achievement) => {
return {
name: achievement.name.toUpperCase(),
unlockTime: achievement.unlockTime * 1000,
unlockTime: achievement.unlockTime,
};
});

View File

@ -1,67 +1,84 @@
import { Cracker } from "@shared";
import { UnlockedAchievement } from "@types";
import { existsSync, createReadStream, readFileSync } from "node:fs";
import readline from "node:readline";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { achievementsLogger } from "../logger";
export const parseAchievementFile = async (
export const parseAchievementFile = (
filePath: string,
type: Cracker
): Promise<UnlockedAchievement[]> => {
): UnlockedAchievement[] => {
if (!existsSync(filePath)) return [];
if (type == Cracker.codex) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processDefault(parsed);
}
if (type == Cracker.rune) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processDefault(parsed);
}
if (type === Cracker.onlineFix) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processOnlineFix(parsed);
}
if (type === Cracker.goldberg) {
const parsed = await jsonParse(filePath);
const parsed = jsonParse(filePath);
return processGoldberg(parsed);
}
if (type == Cracker.userstats) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processUserStats(parsed);
}
if (type == Cracker.rld) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processRld(parsed);
}
if (type === Cracker.skidrow) {
const parsed = await iniParse(filePath);
const parsed = iniParse(filePath);
return processSkidrow(parsed);
}
achievementsLogger.log(`${type} achievements found on ${filePath}`);
if (type === Cracker._3dm) {
const parsed = iniParse(filePath);
return process3DM(parsed);
}
if (type === Cracker.flt) {
const achievements = readdirSync(filePath);
return achievements.map((achievement) => {
return {
name: achievement,
unlockTime: Date.now(),
};
});
}
if (type === Cracker.creamAPI) {
const parsed = iniParse(filePath);
return processCreamAPI(parsed);
}
achievementsLogger.log(
`Unprocessed ${type} achievements found on ${filePath}`
);
return [];
};
const iniParse = async (filePath: string) => {
const iniParse = (filePath: string) => {
try {
const file = createReadStream(filePath);
const lines = readline.createInterface({
input: file,
crlfDelay: Infinity,
});
const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/);
let objectName = "";
const object: Record<string, Record<string, string | number>> = {};
for await (const line of lines) {
for (const line of lines) {
if (line.startsWith("###") || !line.length) continue;
if (line.startsWith("[") && line.endsWith("]")) {
@ -69,13 +86,13 @@ const iniParse = async (filePath: string) => {
object[objectName] = {};
} else {
const [name, ...value] = line.split("=");
object[objectName][name.trim()] = value.join("").trim();
object[objectName][name.trim()] = value.join("=").trim();
}
}
console.log("Parsed ini", object);
return object;
} catch {
} catch (err) {
achievementsLogger.error(`Error parsing ${filePath}`, err);
return null;
}
};
@ -83,7 +100,8 @@ const iniParse = async (filePath: string) => {
const jsonParse = (filePath: string) => {
try {
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
} catch (err) {
achievementsLogger.error(`Error parsing ${filePath}`, err);
return null;
}
};
@ -97,7 +115,28 @@ const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
if (unlockedAchievement?.achieved) {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.timestamp,
unlockTime: unlockedAchievement.timestamp * 1000,
});
}
}
return parsedUnlockedAchievements;
};
const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
const unlockTime = unlockedAchievement.unlocktime;
parsedUnlockedAchievements.push({
name: achievement,
unlockTime:
unlockTime.length === 7
? unlockTime * 1000 * 1000
: unlockTime * 1000,
});
}
}
@ -115,7 +154,7 @@ const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
if (unlockedAchievement[0] === "1") {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement[unlockedAchievement.length - 1],
unlockTime: unlockedAchievement[unlockedAchievement.length - 1] * 1000,
});
}
}
@ -132,13 +171,36 @@ const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
if (unlockedAchievement?.earned) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.earned_time,
unlockTime: unlockedAchievement.earned_time * 1000,
});
}
}
return newUnlockedAchievements;
};
const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["State"];
const times = unlockedAchievements["Time"];
for (const achievement of Object.keys(achievements)) {
if (achievements[achievement] == "0101") {
const time = times[achievement];
newUnlockedAchievements.push({
name: achievement,
unlockTime:
new DataView(
new Uint8Array(Buffer.from(time.toString(), "hex")).buffer
).getUint32(0, true) * 1000,
});
}
}
return newUnlockedAchievements;
};
const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
@ -148,7 +210,7 @@ const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
if (unlockedAchievement?.Achieved) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.UnlockTime,
unlockTime: unlockedAchievement.UnlockTime * 1000,
});
}
}
@ -167,11 +229,12 @@ const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
if (unlockedAchievement?.State) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true),
unlockTime:
new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true) * 1000,
});
}
}
@ -195,8 +258,8 @@ const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => {
if (!isNaN(unlockTime)) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockTime,
name: achievement.replace(/"/g, ``),
unlockTime: unlockTime * 1000,
});
}
}

View File

@ -2,96 +2,82 @@ import { gameAchievementRepository, gameRepository } from "@main/repository";
import {
findAllAchievementFiles,
findAchievementFiles,
findAchievementFileInExecutableDirectory,
getAlternativeObjectIds,
} from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types";
import { getGameAchievementData } from "./get-game-achievement-data";
import { achievementsLogger } from "../logger";
import { Game } from "@main/entity";
export const updateAllLocalUnlockedAchievements = async () => {
const gameAchievementFilesMap = await findAllAchievementFiles();
const gameAchievementFilesMap = findAllAchievementFiles();
for (const objectId of gameAchievementFilesMap.keys()) {
const gameAchievementFiles = gameAchievementFilesMap.get(objectId)!;
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
const [game, localAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop: "steam", isDeleted: false },
}),
gameAchievementRepository.findOne({
where: { objectId, shop: "steam" },
}),
]);
for (const game of games) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const gameAchievementFiles = gameAchievementFilesMap.get(objectId) || [];
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
if (!game) continue;
gameAchievementFiles.push(...achievementFileInsideDirectory);
if (!localAchievements || !localAchievements.achievements) {
await getGameAchievementData(objectId, "steam")
.then((achievements) => {
return gameAchievementRepository.upsert(
{
objectId,
shop: "steam",
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
gameAchievementRepository
.findOne({
where: { objectId: game.objectID, shop: "steam" },
})
.catch(() => {});
}
.then((localAchievements) => {
if (!localAchievements || !localAchievements.achievements) {
getGameAchievementData(game.objectID, "steam");
}
});
const unlockedAchievements: UnlockedAchievement[] = [];
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = await parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
console.log("Parsed for", game.title, parsedAchievements);
if (parsedAchievements.length) {
unlockedAchievements.push(...parsedAchievements);
for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (parsedAchievements.length) {
unlockedAchievements.push(...parsedAchievements);
}
achievementsLogger.log(
"Achievement file for",
game.title,
achievementFile.filePath,
parsedAchievements
);
}
}
mergeAchievements(objectId, "steam", unlockedAchievements, false);
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
}
}
};
export const updateLocalUnlockedAchivements = async (objectId: string) => {
const [game, localAchievements] = await Promise.all([
gameRepository.findOne({
where: { objectID: objectId, shop: "steam", isDeleted: false },
}),
gameAchievementRepository.findOne({
where: { objectId, shop: "steam" },
}),
]);
export const updateLocalUnlockedAchivements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
if (!game) return;
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
const gameAchievementFiles = await findAchievementFiles(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
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(
const localAchievementFile = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
@ -101,5 +87,5 @@ export const updateLocalUnlockedAchivements = async (objectId: string) => {
}
}
mergeAchievements(objectId, "steam", unlockedAchievements, false);
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
};

View File

@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = (
return path.join(logsPath, "pythoninstance.txt");
}
if (message?.scope == "achievements") {
return path.join(logsPath, "achievements.txt");
}
if (message?.level === "error") {
return path.join(logsPath, "error.txt");
}

View File

@ -12,6 +12,6 @@ export const startMainLoop = async () => {
watchAchievements(),
]);
await sleep(1000);
await sleep(1500);
}
};

View File

@ -14,6 +14,7 @@ import type {
} from "@types";
import type { CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
import { GameAchievement } from "@main/entity";
contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
@ -50,8 +51,8 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
getGameAchievements: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAchievements", objectId, shop),
getGameAchievements: (objectId: string, shop: GameShop, userId?: string) =>
ipcRenderer.invoke("getGameAchievements", objectId, shop, userId),
onAchievementUnlocked: (
cb: (
objectId: string,
@ -69,6 +70,22 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onUpdateAchievements: (
objectId: string,
shop: GameShop,
cb: (achievements: GameAchievement[]) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
achievements: GameAchievement[]
) => cb(achievements);
ipcRenderer.on(`on-update-achievements-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-update-achievements-${objectId}-${shop}`,
listener
);
},
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),

View File

@ -132,13 +132,14 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
window.electron.getGameStats(objectId, shop as GameShop).then((result) => {
setStats(result);
});
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.getGameAchievements(objectId, shop as GameShop)
.then((achievements) => {
// TODO: race condition
setAchievements(achievements);
})
.catch(() => {
@ -175,14 +176,11 @@ export function GameDetailsContextProvider({
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(objectId, shop) => {
if (objectId !== objectId || shop !== shop) return;
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.then(setAchievements)
.catch(() => {});
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
shop,
(achievements) => {
setAchievements(achievements);
}
);

View File

@ -66,7 +66,8 @@ declare global {
getTrendingGames: () => Promise<TrendingGame[]>;
getGameAchievements: (
objectId: string,
shop: GameShop
shop: GameShop,
userId?: string
) => Promise<GameAchievement[]>;
onAchievementUnlocked: (
cb: (
@ -75,6 +76,11 @@ declare global {
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => () => Electron.IpcRenderer;
onUpdateAchievements: (
objectId: string,
shop: GameShop,
cb: (achievements: GameAchievement[]) => void
) => () => Electron.IpcRenderer;
/* Library */
addGameToLibrary: (

View File

@ -28,10 +28,11 @@ import {
import { store } from "./store";
import resources from "@locales";
import { Achievement } from "./pages/achievement/achievement";
import { AchievementNotification } from "./pages/achievement/notification/achievement-notification";
import "./workers";
import { RepacksContextProvider } from "./context";
import { Achievement } from "./pages/achievement/achievements";
Sentry.init({});
@ -69,8 +70,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
<Route path="/achievements" Component={Achievement} />
</Route>
<Route path="/achievement-notification" Component={Achievement} />
<Route
path="/achievement-notification"
Component={AchievementNotification}
/>
</Routes>
</HashRouter>
</RepacksContextProvider>

View File

@ -0,0 +1,71 @@
import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { GameAchievement, GameShop } from "@types";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
export function Achievement() {
const [searchParams] = useSearchParams();
const objectId = searchParams.get("objectId");
const shop = searchParams.get("shop");
const userId = searchParams.get("userId");
const { format } = useDate();
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
useEffect(() => {
if (objectId && shop) {
window.electron
.getGameAchievements(objectId, shop as GameShop, userId || undefined)
.then((achievements) => {
setAchievements(achievements);
});
}
}, [objectId, shop, userId]);
return (
<div>
<h1>Achievement</h1>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT * 2}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: "60px",
width: "60px",
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>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { recipe } from "@vanilla-extract/recipes";
import { vars } from "../../theme.css";
import { vars } from "../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
const animationIn = keyframes({

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import * as styles from "./achievement.css";
import * as styles from "./achievement-notification.css";
interface AchievementInfo {
displayName: string;
@ -10,7 +10,7 @@ interface AchievementInfo {
const NOTIFICATION_TIMEOUT = 4000;
export function Achievement() {
export function AchievementNotification() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);

View File

@ -1,7 +1,7 @@
import { useContext, useEffect, useState } from "react";
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import { Button, Link } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context";
@ -29,6 +29,11 @@ export function Sidebar() {
const { numberFormatter } = useFormat();
const buildGameAchievementPath = () => {
const urlParams = new URLSearchParams({ objectId: objectId!, shop });
return `/achievements?${urlParams.toString()}`;
};
useEffect(() => {
if (objectId) {
setHowLongToBeat({ isLoading: true, data: null });
@ -69,44 +74,56 @@ export function Sidebar() {
return (
<aside className={styles.contentSidebar}>
{achievements.length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
<SidebarSection
title={t("achievements", {
unlockedCount: achievements.filter((a) => a.unlocked).length,
achievementsCount: achievements.length,
})}
>
{achievements.map((achievement, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
title={achievement.description}
>
<img
<span>
<Link to={buildGameAchievementPath()}>Ver todas</Link>
</span>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT * 2}px`,
}}
>
{achievements.slice(0, 6).map((achievement, index) => (
<div
key={index}
style={{
height: "72px",
width: "72px",
filter: achievement.unlocked ? "none" : "grayscale(100%)",
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
src={
achievement.unlocked ? achievement.icon : achievement.icongray
}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>
{achievement.unlockTime && format(achievement.unlockTime)}
title={achievement.description}
>
<img
style={{
height: "60px",
width: "60px",
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>
))}
</div>
))}
</div>
</SidebarSection>
)}
{stats && (

View File

@ -35,4 +35,7 @@ export enum Cracker {
skidrow = "SKIDROW",
creamAPI = "CreamAPI",
smartSteamEmu = "SmartSteamEmu",
_3dm = "3dm",
flt = "FLT",
rle = "RLE",
}

109
yarn.lock
View File

@ -1135,6 +1135,13 @@
wrap-ansi "^8.1.0"
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
"@isaacs/fs-minipass@^4.0.0":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32"
integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==
dependencies:
minipass "^7.0.4"
"@jimp/bmp@^0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.22.12.tgz#0316044dc7b1a90274aef266d50349347fb864d4"
@ -2883,11 +2890,6 @@ acorn@^8.8.1, acorn@^8.8.2:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
adm-zip@^0.5.16:
version "0.5.16"
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909"
integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
agent-base@6, agent-base@^6.0.2:
version "6.0.2"
resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"
@ -3042,32 +3044,6 @@ applescript@^1.0.0:
resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
archiver-utils@^5.0.0, archiver-utils@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d"
integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==
dependencies:
glob "^10.0.0"
graceful-fs "^4.2.0"
is-stream "^2.0.1"
lazystream "^1.0.0"
lodash "^4.17.15"
normalize-path "^3.0.0"
readable-stream "^4.0.0"
archiver@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61"
integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==
dependencies:
archiver-utils "^5.0.2"
async "^3.2.4"
buffer-crc32 "^1.0.0"
readable-stream "^4.0.0"
readdir-glob "^1.1.2"
tar-stream "^3.0.0"
zip-stream "^6.0.1"
are-we-there-yet@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd"
@ -3200,7 +3176,7 @@ async-exit-hook@^2.0.1:
resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3"
integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==
async@^3.2.3, async@^3.2.4:
async@^3.2.3:
version "3.2.6"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce"
integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==
@ -5092,18 +5068,6 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
glob@^10.0.0, glob@^10.3.12:
version "10.4.5"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
dependencies:
foreground-child "^3.1.0"
jackspeak "^3.1.2"
minimatch "^9.0.4"
minipass "^7.1.2"
package-json-from-dist "^1.0.0"
path-scurry "^1.11.1"
glob@^10.3.10:
version "10.3.15"
resolved "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz"
@ -5115,6 +5079,18 @@ glob@^10.3.10:
minipass "^7.0.4"
path-scurry "^1.11.0"
glob@^10.3.12, glob@^10.3.7:
version "10.4.5"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
dependencies:
foreground-child "^3.1.0"
jackspeak "^3.1.2"
minimatch "^9.0.4"
minipass "^7.1.2"
package-json-from-dist "^1.0.0"
path-scurry "^1.11.1"
glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
@ -6392,7 +6368,7 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimatch@^5.0.1, minimatch@^5.1.0:
minimatch@^5.0.1:
version "5.1.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
@ -6517,6 +6493,11 @@ mkdirp@^2.1.3:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19"
integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==
mkdirp@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
mlly@^1.4.2, mlly@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f"
@ -7313,24 +7294,6 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readable-stream@^4.0.0:
version "4.5.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09"
integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==
dependencies:
abort-controller "^3.0.0"
buffer "^6.0.3"
events "^3.3.0"
process "^0.11.10"
string_decoder "^1.3.0"
readdir-glob@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584"
integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==
dependencies:
minimatch "^5.1.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@ -7828,17 +7791,6 @@ stop-iteration-iterator@^1.0.0:
dependencies:
internal-slot "^1.0.4"
streamx@^2.15.0:
version "2.20.1"
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.20.1.tgz#471c4f8b860f7b696feb83d5b125caab2fdbb93c"
integrity sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==
dependencies:
fast-fifo "^1.3.2"
queue-tick "^1.0.1"
text-decoder "^1.1.0"
optionalDependencies:
bare-events "^2.2.0"
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@ -8049,15 +8001,6 @@ tar-stream@^2.1.4:
inherits "^2.0.3"
readable-stream "^3.1.1"
tar-stream@^3.0.0:
version "3.1.7"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b"
integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==
dependencies:
b4a "^1.6.4"
fast-fifo "^1.2.0"
streamx "^2.15.0"
tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.1.2:
version "6.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"