mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c55d667bd | ||
|
|
a4bdc3b5f0 | ||
|
|
44dc8f73e8 | ||
|
|
8b8ead531d | ||
|
|
ec40dfdb0b | ||
|
|
74d93da9b3 | ||
|
|
216f813771 | ||
|
|
fee9cfb3e8 | ||
|
|
dcd00cda98 | ||
|
|
c9135715fa | ||
|
|
64cea7ff85 | ||
|
|
382a618c3f | ||
|
|
d906e3f145 | ||
|
|
e987b27aec | ||
|
|
18815a027f | ||
|
|
6c44cc0cc4 | ||
|
|
171c728616 | ||
|
|
b4ff16cfa4 | ||
|
|
eb6317e659 | ||
|
|
8377f85f0b | ||
|
|
10504cdaf8 | ||
|
|
a01c77b424 | ||
|
|
cf818d0f4f | ||
|
|
48e9536169 | ||
|
|
00c589a138 | ||
|
|
30584492af | ||
|
|
aa18b57ada |
@@ -1,4 +1,5 @@
|
||||
MAIN_VITE_API_URL=API_URL
|
||||
MAIN_VITE_AUTH_URL=AUTH_URL
|
||||
MAIN_VITE_WS_URL=
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||
|
||||
@@ -4,3 +4,4 @@ out
|
||||
.gitignore
|
||||
migration.stub
|
||||
hydra-python-rpc/
|
||||
src/main/generated/
|
||||
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -63,6 +64,7 @@ jobs:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
@@ -66,6 +67,7 @@ jobs:
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "proto"]
|
||||
path = proto
|
||||
url = https://github.com/hydralauncher/hydra-protos.git
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.4.9",
|
||||
"version": "3.4.10",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -28,7 +28,8 @@
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -74,6 +75,7 @@
|
||||
"tar": "^7.4.3",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"user-agents": "^1.1.387",
|
||||
"ws": "^8.18.1",
|
||||
"yaml": "^2.6.1",
|
||||
"yup": "^1.5.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -85,6 +87,7 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@protobuf-ts/plugin": "^2.10.0",
|
||||
"@swc/core": "^1.4.16",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/color": "^3.0.6",
|
||||
@@ -97,6 +100,7 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/sound-play": "^1.1.3",
|
||||
"@types/user-agents": "^1.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^31.7.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
|
||||
1
proto
Submodule
1
proto
Submodule
Submodule proto added at 7a23620f93
File diff suppressed because one or more lines are too long
1
sonar-project.properties
Normal file
1
sonar-project.properties
Normal file
@@ -0,0 +1 @@
|
||||
sonar.exclusions=src/main/generated/**
|
||||
@@ -370,10 +370,11 @@
|
||||
"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",
|
||||
"new_friend_request_description": "You have received a new friend request",
|
||||
"new_friend_request_description": "{{displayName}} sent you a friend request",
|
||||
"new_friend_request_title": "New friend request",
|
||||
"extraction_complete": "Extraction complete",
|
||||
"game_extracted": "{{title}} extracted successfully"
|
||||
"game_extracted": "{{title}} extracted successfully",
|
||||
"friend_started_playing_game": "{{displayName}} started playing a game"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
|
||||
@@ -371,7 +371,8 @@
|
||||
"notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados",
|
||||
"new_friend_request_title": "Nueva solicitud de amistad",
|
||||
"new_friend_request_description": "Has recibido una nueva solicitud de amistad"
|
||||
"new_friend_request_description": "{{displayName}} te envió una solicitud de amistad",
|
||||
"friend_started_playing_game": "{{displayName}} está jugando"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
|
||||
@@ -356,9 +356,10 @@
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão",
|
||||
"new_friend_request_title": "Novo pedido de amizade",
|
||||
"new_friend_request_description": "Você recebeu um novo pedido de amizade",
|
||||
"new_friend_request_description": "{{displayName}} te enviou um pedido de amizade",
|
||||
"extraction_complete": "Extração concluída",
|
||||
"game_extracted": "{{title}} extraído com sucesso"
|
||||
"game_extracted": "{{title}} extraído com sucesso",
|
||||
"friend_started_playing_game": "{{displayName}} começou a jogar"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
|
||||
@@ -341,7 +341,8 @@
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicia o Hydra para instalar a nova versão",
|
||||
"new_friend_request_title": "Novo pedido de amizade",
|
||||
"new_friend_request_description": "Recebeste um novo pedido de amizade"
|
||||
"new_friend_request_description": "{{displayName}} te enviou um pedido de amizade",
|
||||
"friend_started_playing_game": "{{displayName}} começou a jogar"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir o Hydra",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
|
||||
import {
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
WSClient,
|
||||
gamesPlaytime,
|
||||
} from "@main/services";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
|
||||
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
@@ -30,6 +35,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
databaseOperations,
|
||||
HydraApi.post("/auth/logout").catch(() => {}),
|
||||
]);
|
||||
|
||||
WSClient.close();
|
||||
};
|
||||
|
||||
registerEvent("signOut", signOut);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { CatalogueCategory } from "@shared";
|
||||
import { ShopAssets } from "@types";
|
||||
|
||||
const getCatalogue = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -11,7 +12,7 @@ const getCatalogue = async (
|
||||
skip: "0",
|
||||
});
|
||||
|
||||
return HydraApi.get(
|
||||
return HydraApi.get<ShopAssets[]>(
|
||||
`/catalogue/${category}?${params.toString()}`,
|
||||
{},
|
||||
{ needsAuth: false }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getSteamAppDetails, logger } from "@main/services";
|
||||
|
||||
import type { ShopDetails, GameShop } from "@types";
|
||||
import type { ShopDetails, GameShop, ShopDetailsWithAssets } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { gamesShopCacheSublevel, levelKeys } from "@main/level";
|
||||
import {
|
||||
gamesShopAssetsSublevel,
|
||||
gamesShopCacheSublevel,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
|
||||
const getLocalizedSteamAppDetails = async (
|
||||
objectId: string,
|
||||
@@ -14,22 +17,7 @@ const getLocalizedSteamAppDetails = async (
|
||||
return getSteamAppDetails(objectId, language);
|
||||
}
|
||||
|
||||
return getSteamAppDetails(objectId, language).then(
|
||||
async (localizedAppDetails) => {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (steamGame && localizedAppDetails) {
|
||||
return {
|
||||
...localizedAppDetails,
|
||||
name: steamGame.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
return getSteamAppDetails(objectId, language);
|
||||
};
|
||||
|
||||
const getGameShopDetails = async (
|
||||
@@ -37,34 +25,44 @@ const getGameShopDetails = async (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
): Promise<ShopDetails | null> => {
|
||||
): Promise<ShopDetailsWithAssets | null> => {
|
||||
if (shop === "steam") {
|
||||
const cachedData = await gamesShopCacheSublevel.get(
|
||||
levelKeys.gameShopCacheItem(shop, objectId, language)
|
||||
);
|
||||
const [cachedData, cachedAssets] = await Promise.all([
|
||||
gamesShopCacheSublevel.get(
|
||||
levelKeys.gameShopCacheItem(shop, objectId, language)
|
||||
),
|
||||
gamesShopAssetsSublevel.get(levelKeys.game(shop, objectId)),
|
||||
]);
|
||||
|
||||
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
|
||||
(result) => {
|
||||
if (result) {
|
||||
result.name = cachedAssets?.title ?? result.name;
|
||||
|
||||
gamesShopCacheSublevel
|
||||
.put(levelKeys.gameShopCacheItem(shop, objectId, language), result)
|
||||
.catch((err) => {
|
||||
logger.error("Could not cache game details", err);
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
assets: cachedAssets ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (cachedData) {
|
||||
return {
|
||||
...cachedData,
|
||||
objectId,
|
||||
} as ShopDetails;
|
||||
assets: cachedAssets ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return Promise.resolve(appDetails);
|
||||
return appDetails;
|
||||
}
|
||||
|
||||
throw new Error("Not implemented");
|
||||
|
||||
@@ -6,17 +6,17 @@ import type { TrendingGame } from "@types";
|
||||
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
const trendingGames = await HydraApi.get<TrendingGame[]>(
|
||||
"/games/trending",
|
||||
"/games/featured",
|
||||
{ language },
|
||||
{ needsAuth: false }
|
||||
).catch(() => []);
|
||||
|
||||
return trendingGames;
|
||||
return trendingGames.slice(0, 1);
|
||||
};
|
||||
|
||||
registerEvent("getTrendingGames", getTrendingGames);
|
||||
|
||||
14
src/main/events/catalogue/save-game-shop-assets.ts
Normal file
14
src/main/events/catalogue/save-game-shop-assets.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { GameShop, ShopAssets } from "@types";
|
||||
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const saveGameShopAssets = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
assets: ShopAssets
|
||||
): Promise<void> => {
|
||||
return gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), assets);
|
||||
};
|
||||
|
||||
registerEvent("saveGameShopAssets", saveGameShopAssets);
|
||||
@@ -3,6 +3,7 @@ import { ipcMain } from "electron";
|
||||
|
||||
import "./catalogue/get-catalogue";
|
||||
import "./catalogue/get-game-shop-details";
|
||||
import "./catalogue/save-game-shop-assets";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { createGame } from "@main/services/library-sync";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import {
|
||||
downloadsSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
gamesSublevel,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
|
||||
const addGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -17,6 +18,8 @@ const addGameToLibrary = async (
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
let game = await gamesSublevel.get(gameKey);
|
||||
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
if (game) {
|
||||
await downloadsSublevel.del(gameKey);
|
||||
|
||||
@@ -24,17 +27,9 @@ const addGameToLibrary = async (
|
||||
|
||||
await gamesSublevel.put(gameKey, game);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
game = {
|
||||
title,
|
||||
iconUrl,
|
||||
iconUrl: gameAssets?.iconUrl ?? null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { LibraryGame } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadsSublevel, gamesSublevel } from "@main/level";
|
||||
import {
|
||||
downloadsSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
gamesSublevel,
|
||||
} from "@main/level";
|
||||
|
||||
const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
return gamesSublevel
|
||||
@@ -12,11 +16,13 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
.filter(([_key, game]) => game.isDeleted === false)
|
||||
.map(async ([key, game]) => {
|
||||
const download = await downloadsSublevel.get(key);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...game,
|
||||
download: download ?? null,
|
||||
...gameAssets,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { db, levelKeys } from "@main/level";
|
||||
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
import { MAIN_LOOP_INTERVAL } from "@main/constants";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi, WindowManager } from "@main/services";
|
||||
import { publishNewFriendRequestNotification } from "@main/services/notifications";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import type { FriendRequestSync } from "@types";
|
||||
|
||||
interface SyncState {
|
||||
friendRequestCount: number | null;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
const ticksToUpdate = (2 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 2 minutes
|
||||
|
||||
const syncState: SyncState = {
|
||||
friendRequestCount: null,
|
||||
tick: 0,
|
||||
};
|
||||
|
||||
const syncFriendRequests = async () => {
|
||||
export const syncFriendRequests = async () => {
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
|
||||
.then((res) => {
|
||||
if (
|
||||
syncState.friendRequestCount != null &&
|
||||
syncState.friendRequestCount < res.friendRequestCount
|
||||
) {
|
||||
publishNewFriendRequestNotification();
|
||||
}
|
||||
|
||||
syncState.friendRequestCount = res.friendRequestCount;
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-sync-friend-requests",
|
||||
res
|
||||
@@ -44,16 +21,4 @@ const syncFriendRequests = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const syncFriendRequestsEvent = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return syncFriendRequests();
|
||||
};
|
||||
|
||||
export const watchFriendRequests = async () => {
|
||||
if (syncState.tick % ticksToUpdate === 0) {
|
||||
await syncFriendRequests();
|
||||
}
|
||||
|
||||
syncState.tick++;
|
||||
};
|
||||
|
||||
registerEvent("syncFriendRequests", syncFriendRequestsEvent);
|
||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { Download, StartGameDownloadPayload } from "@types";
|
||||
import { DownloadManager, HydraApi, logger } from "@main/services";
|
||||
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { createGame } from "@main/services/library-sync";
|
||||
import { Downloader, DownloadError, steamUrlBuilder } from "@shared";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { Downloader, DownloadError } from "@shared";
|
||||
import {
|
||||
downloadsSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
gamesSublevel,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
const startGameDownload = async (
|
||||
@@ -36,6 +39,7 @@ const startGameDownload = async (
|
||||
}
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
/* Delete any previous download */
|
||||
await downloadsSublevel.del(gameKey);
|
||||
@@ -46,17 +50,9 @@ const startGameDownload = async (
|
||||
isDeleted: false,
|
||||
});
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gamesSublevel.put(gameKey, {
|
||||
title,
|
||||
iconUrl,
|
||||
iconUrl: gameAssets?.iconUrl ?? null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
|
||||
@@ -16,7 +16,7 @@ const updateUserPreferences = async (
|
||||
|
||||
if (preferences.language) {
|
||||
await db.put<string, string>(levelKeys.language, preferences.language, {
|
||||
valueEncoding: "utf-8",
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
|
||||
i18next.changeLanguage(preferences.language);
|
||||
|
||||
@@ -1,97 +1,12 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { UserProfile } from "@types";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
const getSteamGame = async (objectId: string) => {
|
||||
try {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
return {
|
||||
title: steamGame.name as string,
|
||||
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error("Failed to get Steam game", err);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getUser = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string
|
||||
): Promise<UserProfile | null> => {
|
||||
try {
|
||||
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const recentGames = await Promise.all(
|
||||
profile.recentGames
|
||||
.map(async (game) => {
|
||||
const steamGame = await getSteamGame(game.objectId);
|
||||
|
||||
return {
|
||||
...game,
|
||||
...steamGame,
|
||||
};
|
||||
})
|
||||
.filter((game) => game)
|
||||
);
|
||||
|
||||
const libraryGames = await Promise.all(
|
||||
profile.libraryGames
|
||||
.map(async (game) => {
|
||||
const steamGame = await getSteamGame(game.objectId);
|
||||
|
||||
return {
|
||||
...game,
|
||||
...steamGame,
|
||||
};
|
||||
})
|
||||
.filter((game) => game)
|
||||
);
|
||||
|
||||
if (profile.currentGame) {
|
||||
const steamGame = await getSteamGame(profile.currentGame.objectId);
|
||||
|
||||
if (steamGame) {
|
||||
profile.currentGame = {
|
||||
...profile.currentGame,
|
||||
...steamGame,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const friends = await Promise.all(
|
||||
profile.friends.map(async (friend) => {
|
||||
if (!friend.currentGame) return friend;
|
||||
|
||||
const currentGame = await getSteamGame(friend.currentGame.objectId);
|
||||
|
||||
return {
|
||||
...friend,
|
||||
currentGame: {
|
||||
...friend.currentGame,
|
||||
...currentGame,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
friends,
|
||||
libraryGames,
|
||||
recentGames,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
|
||||
};
|
||||
|
||||
registerEvent("getUser", getUser);
|
||||
|
||||
352
src/main/generated/envelope.ts
Normal file
352
src/main/generated/envelope.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// @generated by protobuf-ts 2.10.0
|
||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||
// tslint:disable
|
||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||
import type { IBinaryWriter } from "@protobuf-ts/runtime";
|
||||
import { WireType } from "@protobuf-ts/runtime";
|
||||
import type { BinaryReadOptions } from "@protobuf-ts/runtime";
|
||||
import type { IBinaryReader } from "@protobuf-ts/runtime";
|
||||
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
|
||||
import type { PartialMessage } from "@protobuf-ts/runtime";
|
||||
import { reflectionMergePartial } from "@protobuf-ts/runtime";
|
||||
import { MessageType } from "@protobuf-ts/runtime";
|
||||
/**
|
||||
* @generated from protobuf message FriendRequest
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/**
|
||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
||||
*/
|
||||
friendRequestCount: number;
|
||||
/**
|
||||
* @generated from protobuf field: optional string sender_id = 2;
|
||||
*/
|
||||
senderId?: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message FriendGameSession
|
||||
*/
|
||||
export interface FriendGameSession {
|
||||
/**
|
||||
* @generated from protobuf field: string object_id = 1;
|
||||
*/
|
||||
objectId: string;
|
||||
/**
|
||||
* @generated from protobuf field: string shop = 2;
|
||||
*/
|
||||
shop: string;
|
||||
/**
|
||||
* @generated from protobuf field: string friend_id = 3;
|
||||
*/
|
||||
friendId: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Envelope
|
||||
*/
|
||||
export interface Envelope {
|
||||
/**
|
||||
* @generated from protobuf oneof: payload
|
||||
*/
|
||||
payload:
|
||||
| {
|
||||
oneofKind: "friendRequest";
|
||||
/**
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
||||
*/
|
||||
friendRequest: FriendRequest;
|
||||
}
|
||||
| {
|
||||
oneofKind: "friendGameSession";
|
||||
/**
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
||||
*/
|
||||
friendGameSession: FriendGameSession;
|
||||
}
|
||||
| {
|
||||
oneofKind: undefined;
|
||||
};
|
||||
}
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class FriendRequest$Type extends MessageType<FriendRequest> {
|
||||
constructor() {
|
||||
super("FriendRequest", [
|
||||
{
|
||||
no: 1,
|
||||
name: "friend_request_count",
|
||||
kind: "scalar",
|
||||
T: 5 /*ScalarType.INT32*/,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
name: "sender_id",
|
||||
kind: "scalar",
|
||||
opt: true,
|
||||
T: 9 /*ScalarType.STRING*/,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<FriendRequest>): FriendRequest {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.friendRequestCount = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<FriendRequest>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: FriendRequest
|
||||
): FriendRequest {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* int32 friend_request_count */ 1:
|
||||
message.friendRequestCount = reader.int32();
|
||||
break;
|
||||
case /* optional string sender_id */ 2:
|
||||
message.senderId = reader.string();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: FriendRequest,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* int32 friend_request_count = 1; */
|
||||
if (message.friendRequestCount !== 0)
|
||||
writer.tag(1, WireType.Varint).int32(message.friendRequestCount);
|
||||
/* optional string sender_id = 2; */
|
||||
if (message.senderId !== undefined)
|
||||
writer.tag(2, WireType.LengthDelimited).string(message.senderId);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message FriendRequest
|
||||
*/
|
||||
export const FriendRequest = new FriendRequest$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
||||
constructor() {
|
||||
super("FriendGameSession", [
|
||||
{ no: 1, name: "object_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 2, name: "shop", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
{ no: 3, name: "friend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<FriendGameSession>): FriendGameSession {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.objectId = "";
|
||||
message.shop = "";
|
||||
message.friendId = "";
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<FriendGameSession>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: FriendGameSession
|
||||
): FriendGameSession {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* string object_id */ 1:
|
||||
message.objectId = reader.string();
|
||||
break;
|
||||
case /* string shop */ 2:
|
||||
message.shop = reader.string();
|
||||
break;
|
||||
case /* string friend_id */ 3:
|
||||
message.friendId = reader.string();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: FriendGameSession,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* string object_id = 1; */
|
||||
if (message.objectId !== "")
|
||||
writer.tag(1, WireType.LengthDelimited).string(message.objectId);
|
||||
/* string shop = 2; */
|
||||
if (message.shop !== "")
|
||||
writer.tag(2, WireType.LengthDelimited).string(message.shop);
|
||||
/* string friend_id = 3; */
|
||||
if (message.friendId !== "")
|
||||
writer.tag(3, WireType.LengthDelimited).string(message.friendId);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message FriendGameSession
|
||||
*/
|
||||
export const FriendGameSession = new FriendGameSession$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Envelope$Type extends MessageType<Envelope> {
|
||||
constructor() {
|
||||
super("Envelope", [
|
||||
{
|
||||
no: 1,
|
||||
name: "friend_request",
|
||||
kind: "message",
|
||||
oneof: "payload",
|
||||
T: () => FriendRequest,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
name: "friend_game_session",
|
||||
kind: "message",
|
||||
oneof: "payload",
|
||||
T: () => FriendGameSession,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Envelope>): Envelope {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.payload = { oneofKind: undefined };
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<Envelope>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: Envelope
|
||||
): Envelope {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* FriendRequest friend_request */ 1:
|
||||
message.payload = {
|
||||
oneofKind: "friendRequest",
|
||||
friendRequest: FriendRequest.internalBinaryRead(
|
||||
reader,
|
||||
reader.uint32(),
|
||||
options,
|
||||
(message.payload as any).friendRequest
|
||||
),
|
||||
};
|
||||
break;
|
||||
case /* FriendGameSession friend_game_session */ 2:
|
||||
message.payload = {
|
||||
oneofKind: "friendGameSession",
|
||||
friendGameSession: FriendGameSession.internalBinaryRead(
|
||||
reader,
|
||||
reader.uint32(),
|
||||
options,
|
||||
(message.payload as any).friendGameSession
|
||||
),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: Envelope,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* FriendRequest friend_request = 1; */
|
||||
if (message.payload.oneofKind === "friendRequest")
|
||||
FriendRequest.internalBinaryWrite(
|
||||
message.payload.friendRequest,
|
||||
writer.tag(1, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
/* FriendGameSession friend_game_session = 2; */
|
||||
if (message.payload.oneofKind === "friendGameSession")
|
||||
FriendGameSession.internalBinaryWrite(
|
||||
message.payload.friendGameSession,
|
||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message Envelope
|
||||
*/
|
||||
export const Envelope = new Envelope$Type();
|
||||
@@ -59,9 +59,11 @@ app.whenReady().then(async () => {
|
||||
|
||||
await loadState();
|
||||
|
||||
const language = await db.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
});
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.catch(() => "en");
|
||||
|
||||
if (language) i18n.changeLanguage(language);
|
||||
|
||||
|
||||
11
src/main/level/sublevels/game-shop-assets.ts
Normal file
11
src/main/level/sublevels/game-shop-assets.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ShopAssets } from "@types";
|
||||
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
|
||||
levelKeys.gameShopAssets,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./downloads";
|
||||
export * from "./games";
|
||||
export * from "./game-shop-assets";
|
||||
export * from "./game-shop-cache";
|
||||
export * from "./game-achievements";
|
||||
export * from "./keys";
|
||||
|
||||
@@ -6,6 +6,7 @@ export const levelKeys = {
|
||||
user: "user",
|
||||
auth: "auth",
|
||||
themes: "themes",
|
||||
gameShopAssets: "gameShopAssets",
|
||||
gameShopCache: "gameShopCache",
|
||||
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
|
||||
`${shop}:${objectId}:${language}`,
|
||||
@@ -14,4 +15,5 @@ export const levelKeys = {
|
||||
userPreferences: "userPreferences",
|
||||
language: "language",
|
||||
screenState: "screenState",
|
||||
rpcPassword: "rpcPassword",
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Aria2, DownloadManager, Ludusavi, startMainLoop } from "./services";
|
||||
import { RealDebridClient } from "./services/download/real-debrid";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
import { TorBoxClient } from "./services/download/torbox";
|
||||
import { CommonRedistManager } from "./services/common-redist-manager";
|
||||
import { SystemPath } from "./services/system-path";
|
||||
import {
|
||||
WSClient,
|
||||
SystemPath,
|
||||
CommonRedistManager,
|
||||
TorBoxClient,
|
||||
RealDebridClient,
|
||||
Aria2,
|
||||
DownloadManager,
|
||||
Ludusavi,
|
||||
HydraApi,
|
||||
uploadGamesBatch,
|
||||
startMainLoop,
|
||||
} from "@main/services";
|
||||
|
||||
export const loadState = async () => {
|
||||
SystemPath.checkIfPathsAreAvailable();
|
||||
@@ -37,6 +43,7 @@ export const loadState = async () => {
|
||||
|
||||
await HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
WSClient.connect();
|
||||
});
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
|
||||
@@ -25,7 +25,7 @@ export const getGameAchievementData = async (
|
||||
|
||||
const language = await db
|
||||
.get<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf-8",
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./real-debrid";
|
||||
export * from "./torbox";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
|
||||
import { db } from "@main/level";
|
||||
import { levelKeys } from "@main/level/sublevels";
|
||||
import type { Auth, User } from "@types";
|
||||
import { WSClient } from "./ws/ws-client";
|
||||
|
||||
interface HydraApiOptions {
|
||||
needsAuth?: boolean;
|
||||
@@ -101,6 +102,8 @@ export class HydraApi {
|
||||
WindowManager.mainWindow.webContents.send("on-signin");
|
||||
await clearGamesRemoteIds();
|
||||
uploadGamesBatch();
|
||||
WSClient.close();
|
||||
WSClient.connect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,6 @@ export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
export * from "./system-path";
|
||||
export * from "./library-sync";
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ShopAssets } from "@types";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { gamesShopAssetsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
|
||||
type ProfileGame = {
|
||||
id: string;
|
||||
lastTimePlayed: Date | null;
|
||||
playTimeInMilliseconds: number;
|
||||
} & ShopAssets;
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
return HydraApi.get("/profile/games")
|
||||
return HydraApi.get<ProfileGame[]>("/profile/games")
|
||||
.then(async (response) => {
|
||||
for (const game of response) {
|
||||
const localGame = await gamesSublevel.get(
|
||||
@@ -31,25 +36,32 @@ export const mergeWithRemoteGames = async () => {
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
});
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
|
||||
objectId: game.objectId,
|
||||
title: steamGame?.name,
|
||||
title: game.title,
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl,
|
||||
iconUrl: game.iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
isDeleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
await gamesShopAssetsSublevel.put(
|
||||
levelKeys.game(game.shop, game.objectId),
|
||||
{
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: game.title,
|
||||
coverImageUrl: game.coverImageUrl,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||
libraryImageUrl: game.libraryImageUrl,
|
||||
logoImageUrl: game.logoImageUrl,
|
||||
iconUrl: game.iconUrl,
|
||||
logoPosition: game.logoPosition,
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DownloadManager } from "./download";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
|
||||
import { UpdateManager } from "./update-manager";
|
||||
import { watchFriendRequests } from "@main/events/profile/sync-friend-requests";
|
||||
import { MAIN_LOOP_INTERVAL } from "@main/constants";
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
@@ -11,7 +10,6 @@ export const startMainLoop = async () => {
|
||||
while (true) {
|
||||
await Promise.allSettled([
|
||||
watchProcesses(),
|
||||
watchFriendRequests(),
|
||||
DownloadManager.watchDownloads(),
|
||||
AchievementWatcherManager.watchAchievements(),
|
||||
DownloadManager.getSeedStatus(),
|
||||
|
||||
@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
|
||||
import { NotificationOptions, toXmlString } from "./xml";
|
||||
import { logger } from "../logger";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { Game, UserPreferences } from "@types";
|
||||
import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
|
||||
import { SystemPath } from "../system-path";
|
||||
@@ -81,7 +81,9 @@ export const publishNotificationUpdateReadyToInstall = async (
|
||||
.show();
|
||||
};
|
||||
|
||||
export const publishNewFriendRequestNotification = async () => {
|
||||
export const publishNewFriendRequestNotification = async (
|
||||
user: UserProfile
|
||||
) => {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
@@ -97,8 +99,27 @@ export const publishNewFriendRequestNotification = async () => {
|
||||
}),
|
||||
body: t("new_friend_request_description", {
|
||||
ns: "notifications",
|
||||
displayName: user.displayName,
|
||||
}),
|
||||
icon: trayIcon,
|
||||
icon: user?.profileImageUrl
|
||||
? await downloadImage(user.profileImageUrl)
|
||||
: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
export const publishFriendStartedPlayingGameNotification = async (
|
||||
friend: UserProfile,
|
||||
game: GameStats
|
||||
) => {
|
||||
new Notification({
|
||||
title: t("friend_started_playing_game", {
|
||||
ns: "notifications",
|
||||
displayName: friend.displayName,
|
||||
}),
|
||||
body: game.assets?.title,
|
||||
icon: friend?.profileImageUrl
|
||||
? await downloadImage(friend.profileImageUrl)
|
||||
: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import crypto from "node:crypto";
|
||||
|
||||
import { pythonRpcLogger } from "./logger";
|
||||
import { Readable } from "node:stream";
|
||||
import { app, dialog } from "electron";
|
||||
import { app, dialog, safeStorage } from "electron";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
|
||||
interface GamePayload {
|
||||
game_id: string;
|
||||
@@ -30,17 +31,12 @@ const rustBinaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
export class PythonRPC {
|
||||
public static readonly BITTORRENT_PORT = "5881";
|
||||
public static readonly RPC_PORT = "8084";
|
||||
private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
|
||||
public static readonly rpc = axios.create({
|
||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||
headers: {
|
||||
"x-hydra-rpc-password": this.RPC_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
|
||||
private static logStderr(readable: Readable | null) {
|
||||
if (!readable) return;
|
||||
|
||||
@@ -48,14 +44,37 @@ export class PythonRPC {
|
||||
readable.on("data", pythonRpcLogger.log);
|
||||
}
|
||||
|
||||
public static spawn(
|
||||
private static async getRPCPassword() {
|
||||
const existingPassword = await db.get(levelKeys.rpcPassword, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
|
||||
if (existingPassword)
|
||||
return safeStorage.decryptString(Buffer.from(existingPassword, "hex"));
|
||||
|
||||
const newPassword = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
await db.put(
|
||||
levelKeys.rpcPassword,
|
||||
safeStorage.encryptString(newPassword).toString("hex"),
|
||||
{
|
||||
valueEncoding: "utf8",
|
||||
}
|
||||
);
|
||||
|
||||
return newPassword;
|
||||
}
|
||||
|
||||
public static async spawn(
|
||||
initialDownload?: GamePayload,
|
||||
initialSeeding?: GamePayload[]
|
||||
) {
|
||||
const rpcPassword = await this.getRPCPassword();
|
||||
|
||||
const commonArgs = [
|
||||
this.BITTORRENT_PORT,
|
||||
this.RPC_PORT,
|
||||
this.RPC_PASSWORD,
|
||||
rpcPassword,
|
||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||
app.isPackaged
|
||||
@@ -116,6 +135,8 @@ export class PythonRPC {
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
}
|
||||
|
||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
|
||||
17
src/main/services/ws/events/friend-game-session.ts
Normal file
17
src/main/services/ws/events/friend-game-session.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FriendGameSession } from "@main/generated/envelope";
|
||||
import { HydraApi } from "@main/services/hydra-api";
|
||||
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
|
||||
import { GameStats } from "@types";
|
||||
|
||||
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
|
||||
const [friend, gameStats] = await Promise.all([
|
||||
HydraApi.get(`/users/${payload.friendId}`),
|
||||
HydraApi.get<GameStats>(
|
||||
`/games/stats?objectId=${payload.objectId}&shop=steam`
|
||||
),
|
||||
]);
|
||||
|
||||
if (friend && gameStats) {
|
||||
publishFriendStartedPlayingGameNotification(friend, gameStats);
|
||||
}
|
||||
};
|
||||
16
src/main/services/ws/events/friend-request.ts
Normal file
16
src/main/services/ws/events/friend-request.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FriendRequest } from "@main/generated/envelope";
|
||||
import { HydraApi } from "@main/services/hydra-api";
|
||||
import { publishNewFriendRequestNotification } from "@main/services/notifications";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
|
||||
export const friendRequestEvent = async (payload: FriendRequest) => {
|
||||
WindowManager.mainWindow?.webContents.send("on-sync-friend-requests", {
|
||||
friendRequestCount: payload.friendRequestCount,
|
||||
});
|
||||
|
||||
const user = await HydraApi.get(`/users/${payload.senderId}`);
|
||||
|
||||
if (user) {
|
||||
publishNewFriendRequestNotification(user);
|
||||
}
|
||||
};
|
||||
1
src/main/services/ws/index.ts
Normal file
1
src/main/services/ws/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./ws-client";
|
||||
119
src/main/services/ws/ws-client.ts
Normal file
119
src/main/services/ws/ws-client.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { Envelope } from "@main/generated/envelope";
|
||||
import { logger } from "../logger";
|
||||
import { friendRequestEvent } from "./events/friend-request";
|
||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||
|
||||
export class WSClient {
|
||||
private static ws: WebSocket | null = null;
|
||||
private static reconnectInterval = 1_000;
|
||||
private static readonly maxReconnectInterval = 30_000;
|
||||
private static shouldReconnect = true;
|
||||
private static reconnecting = false;
|
||||
private static heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
static async connect() {
|
||||
this.shouldReconnect = true;
|
||||
|
||||
try {
|
||||
const { token } = await HydraApi.post<{ token: string }>("/auth/ws");
|
||||
|
||||
this.ws = new WebSocket(import.meta.env.MAIN_VITE_WS_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
this.ws.on("open", () => {
|
||||
logger.info("WS connected");
|
||||
this.reconnectInterval = 1000;
|
||||
this.reconnecting = false;
|
||||
|
||||
this.startHeartbeat();
|
||||
});
|
||||
|
||||
this.ws.on("message", (message) => {
|
||||
const envelope = Envelope.fromBinary(
|
||||
new Uint8Array(Buffer.from(message.toString()))
|
||||
);
|
||||
|
||||
logger.info("Received WS envelope:", envelope);
|
||||
|
||||
if (envelope.payload.oneofKind === "friendRequest") {
|
||||
friendRequestEvent(envelope.payload.friendRequest);
|
||||
}
|
||||
|
||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||
this.ws.on("error", (err) => {
|
||||
logger.error("WS error:", err);
|
||||
this.handleDisconnect("error");
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect WS:", err);
|
||||
this.handleDisconnect("auth-failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static handleDisconnect(reason: string) {
|
||||
logger.warn(`WS disconnected due to ${reason}`);
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.cleanupSocket();
|
||||
this.tryReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static async tryReconnect() {
|
||||
if (this.reconnecting) return;
|
||||
this.reconnecting = true;
|
||||
|
||||
logger.info(`Reconnecting in ${this.reconnectInterval / 1000}s...`);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (err) {
|
||||
logger.error("Reconnect failed:", err);
|
||||
this.reconnectInterval = Math.min(
|
||||
this.reconnectInterval * 2,
|
||||
this.maxReconnectInterval
|
||||
);
|
||||
this.reconnecting = false;
|
||||
this.tryReconnect();
|
||||
}
|
||||
}, this.reconnectInterval);
|
||||
}
|
||||
|
||||
private static cleanupSocket() {
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static close() {
|
||||
this.shouldReconnect = false;
|
||||
this.reconnecting = false;
|
||||
this.cleanupSocket();
|
||||
}
|
||||
|
||||
private static startHeartbeat() {
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.ping();
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
}
|
||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_AUTH_URL: string;
|
||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||
readonly MAIN_VITE_WS_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import path from "node:path";
|
||||
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
|
||||
|
||||
import Piscina from "piscina";
|
||||
|
||||
import { seedsPath } from "@main/constants";
|
||||
|
||||
export const steamGamesWorker = new Piscina({
|
||||
filename: steamGamesWorkerPath,
|
||||
workerData: {
|
||||
steamGamesPath: path.join(seedsPath, "steam-games.json"),
|
||||
},
|
||||
maxThreads: 1,
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { SteamGame } from "@types";
|
||||
import { slice } from "lodash-es";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { workerData } from "node:worker_threads";
|
||||
|
||||
const { steamGamesPath } = workerData;
|
||||
|
||||
const data = fs.readFileSync(steamGamesPath, "utf-8");
|
||||
|
||||
const steamGames = JSON.parse(data) as SteamGame[];
|
||||
|
||||
export const getById = (id: number) =>
|
||||
steamGames.find((game) => game.id === id);
|
||||
|
||||
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
|
||||
slice(steamGames, offset, offset + limit);
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
Theme,
|
||||
FriendRequestSync,
|
||||
ShortcutLocation,
|
||||
ShopAssets,
|
||||
} from "@types";
|
||||
import type { AuthPage, CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
@@ -64,6 +65,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("searchGames", payload, take, skip),
|
||||
getCatalogue: (category: CatalogueCategory) =>
|
||||
ipcRenderer.invoke("getCatalogue", category),
|
||||
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
|
||||
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
|
||||
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
|
||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setGameRunning,
|
||||
setFriendRequestCount,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
@@ -155,16 +154,6 @@ export function App() {
|
||||
});
|
||||
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
|
||||
dispatch(setFriendRequestCount(result.friendRequestCount));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
|
||||
if (gamesRunning.length) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useState, useMemo } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
@@ -63,7 +62,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
>
|
||||
<div className="game-card__backdrop">
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
src={game.libraryImageUrl}
|
||||
alt={game.title}
|
||||
className="game-card__cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -33,29 +33,27 @@ export function Hero() {
|
||||
}
|
||||
|
||||
if (featuredGameDetails?.length) {
|
||||
return featuredGameDetails.map((game, index) => (
|
||||
return featuredGameDetails.map((game) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(game.uri)}
|
||||
className="hero"
|
||||
key={index}
|
||||
key={game.uri}
|
||||
>
|
||||
<div className="hero__backdrop">
|
||||
<img
|
||||
src={game.background}
|
||||
alt={game.description}
|
||||
src={game.libraryHeroImageUrl}
|
||||
alt={game.description ?? ""}
|
||||
className="hero__media"
|
||||
/>
|
||||
|
||||
<div className="hero__content">
|
||||
{game.logo && (
|
||||
<img
|
||||
src={game.logo}
|
||||
width="250px"
|
||||
alt={game.description}
|
||||
loading="eager"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={game.logoImageUrl}
|
||||
width="250px"
|
||||
alt={game.description ?? ""}
|
||||
loading="eager"
|
||||
/>
|
||||
<p className="hero__description">{game.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { sortBy } from "lodash-es";
|
||||
import cn from "classnames";
|
||||
import { CommentDiscussionIcon } from "@primer/octicons-react";
|
||||
import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -33,6 +35,8 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
|
||||
export function Sidebar() {
|
||||
const filterRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const navigate = useNavigate();
|
||||
@@ -60,6 +64,16 @@ export function Sidebar() {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
|
||||
dispatch(setFriendRequestCount(result.friendRequestCount));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
|
||||
const cursorPos = useRef({ x: 0 });
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
GameShop,
|
||||
GameStats,
|
||||
LibraryGame,
|
||||
ShopDetails,
|
||||
ShopDetailsWithAssets,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
|
||||
@@ -69,7 +69,9 @@ export function GameDetailsContextProvider({
|
||||
gameTitle,
|
||||
shop,
|
||||
}: Readonly<GameDetailsContextProps>) {
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetailsWithAssets | null>(
|
||||
null
|
||||
);
|
||||
const [achievements, setAchievements] = useState<UserAchievement[] | null>(
|
||||
null
|
||||
);
|
||||
@@ -79,7 +81,7 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [gameColor, setGameColor] = useState("");
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
@@ -120,7 +122,7 @@ export function GameDetailsContextProvider({
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
window.electron
|
||||
const shopDetailsPromise = window.electron
|
||||
.getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language))
|
||||
.then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -135,15 +137,42 @@ export function GameDetailsContextProvider({
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (result?.assets) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.electron.getGameStats(objectId, shop).then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setStats(result);
|
||||
});
|
||||
const statsPromise = window.electron
|
||||
.getGameStats(objectId, shop)
|
||||
.then((result) => {
|
||||
if (abortController.signal.aborted) return null;
|
||||
setStats(result);
|
||||
return result;
|
||||
});
|
||||
|
||||
Promise.all([shopDetailsPromise, statsPromise])
|
||||
.then(([_, stats]) => {
|
||||
if (stats) {
|
||||
const assets = stats.assets;
|
||||
if (assets) {
|
||||
window.electron.saveGameShopAssets(objectId, shop, assets);
|
||||
|
||||
setShopDetails((prev) => {
|
||||
if (!prev) return null;
|
||||
console.log("assets", assets);
|
||||
return {
|
||||
...prev,
|
||||
assets,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
if (userDetails) {
|
||||
window.electron
|
||||
|
||||
@@ -3,13 +3,13 @@ import type {
|
||||
GameShop,
|
||||
GameStats,
|
||||
LibraryGame,
|
||||
ShopDetails,
|
||||
ShopDetailsWithAssets,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
game: LibraryGame | null;
|
||||
shopDetails: ShopDetails | null;
|
||||
shopDetails: ShopDetailsWithAssets | null;
|
||||
repacks: GameRepack[];
|
||||
shop: GameShop;
|
||||
gameTitle: string;
|
||||
|
||||
15
src/renderer/src/declaration.d.ts
vendored
15
src/renderer/src/declaration.d.ts
vendored
@@ -3,7 +3,6 @@ import type {
|
||||
AppUpdaterEvent,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
Steam250Game,
|
||||
DownloadProgress,
|
||||
SeedingStatus,
|
||||
@@ -33,6 +32,9 @@ import type {
|
||||
Badge,
|
||||
Auth,
|
||||
ShortcutLocation,
|
||||
CatalogueSearchResult,
|
||||
ShopAssets,
|
||||
ShopDetailsWithAssets,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -69,13 +71,18 @@ declare global {
|
||||
payload: CatalogueSearchPayload,
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<{ edges: any[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
|
||||
saveGameShopAssets: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
assets: ShopAssets
|
||||
) => Promise<void>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
) => Promise<ShopDetails | null>;
|
||||
) => Promise<ShopDetailsWithAssets | null>;
|
||||
getRandomGame: () => Promise<Steam250Game>;
|
||||
getHowLongToBeat: (
|
||||
objectId: string,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -120,8 +119,15 @@ export function AchievementsContent({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
||||
|
||||
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
|
||||
useContext(gameDetailsContext);
|
||||
const {
|
||||
gameTitle,
|
||||
objectId,
|
||||
shop,
|
||||
shopDetails,
|
||||
achievements,
|
||||
gameColor,
|
||||
setGameColor,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -131,10 +137,13 @@ export function AchievementsContent({
|
||||
}, [dispatch, gameTitle]);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
});
|
||||
const output = await average(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ?? "",
|
||||
{
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
}
|
||||
);
|
||||
|
||||
const backgroundColor = output
|
||||
? (new Color(output).darken(0.7).toString() as string)
|
||||
@@ -179,7 +188,7 @@ export function AchievementsContent({
|
||||
return (
|
||||
<div className="achievements-content__achievements-list">
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectId)}
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__image"
|
||||
alt={gameTitle}
|
||||
onLoad={handleHeroLoad}
|
||||
@@ -205,7 +214,7 @@ export function AchievementsContent({
|
||||
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
|
||||
>
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectId)}
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__section__container__hero__content__game-logo"
|
||||
alt={gameTitle}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { CatalogueSearchResult, DownloadSource } from "@types";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
@@ -44,7 +44,7 @@ export default function Catalogue() {
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
|
||||
|
||||
const [itemsCount, setItemsCount] = useState(0);
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@
|
||||
border-right: 1px solid globals.$border-color;
|
||||
}
|
||||
|
||||
&__cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: globals.$body-color;
|
||||
width: 200px;
|
||||
height: 103px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
rgba(255, 255, 255, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Badge } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useAppSelector, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import "./game-item.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CatalogueSearchResult } from "@types";
|
||||
import { QuestionIcon } from "@primer/octicons-react";
|
||||
|
||||
export interface GameItemProps {
|
||||
game: any;
|
||||
game: CatalogueSearchResult;
|
||||
}
|
||||
|
||||
export function GameItem({ game }: GameItemProps) {
|
||||
@@ -43,18 +44,32 @@ export function GameItem({ game }: GameItemProps) {
|
||||
});
|
||||
}, [game.genres, language, steamGenres]);
|
||||
|
||||
const libraryImage = useMemo(() => {
|
||||
if (game.libraryImageUrl) {
|
||||
return (
|
||||
<img
|
||||
className="game-item__cover"
|
||||
src={game.libraryImageUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-item__cover-placeholder">
|
||||
<QuestionIcon size={28} />
|
||||
</div>
|
||||
);
|
||||
}, [game.libraryImageUrl, game.title]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="game-item"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
<img
|
||||
className="game-item__cover"
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
{libraryImage}
|
||||
|
||||
<div className="game-item__details">
|
||||
<span>{game.title}</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
@@ -319,7 +319,7 @@ export function DownloadGroup({
|
||||
<div className="download-group__cover">
|
||||
<div className="download-group__cover-backdrop">
|
||||
<img
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
src={game.libraryImageUrl ?? ""}
|
||||
className="download-group__cover-image"
|
||||
alt={game.title}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Sidebar } from "./sidebar/sidebar";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { AuthPage, steamUrlBuilder } from "@shared";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
@@ -59,10 +59,13 @@ export function GameDetailsContent() {
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
});
|
||||
const output = await average(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ?? "",
|
||||
{
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
}
|
||||
);
|
||||
|
||||
const backgroundColor = output
|
||||
? new Color(output).darken(0.7).toString()
|
||||
@@ -100,7 +103,7 @@ export function GameDetailsContent() {
|
||||
<section className="game-details__container">
|
||||
<div ref={heroRef} className="game-details__hero">
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectId!)}
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
@@ -119,7 +122,7 @@ export function GameDetailsContent() {
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectId!)}
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import { Button, GameCard, Hero } from "@renderer/components";
|
||||
import type { Steam250Game } from "@types";
|
||||
import type { ShopAssets, Steam250Game } from "@types";
|
||||
|
||||
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
|
||||
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
|
||||
@@ -27,7 +27,9 @@ export default function Home() {
|
||||
CatalogueCategory.Hot
|
||||
);
|
||||
|
||||
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, ShopAssets[]>
|
||||
>({
|
||||
[CatalogueCategory.Hot]: [],
|
||||
[CatalogueCategory.Weekly]: [],
|
||||
[CatalogueCategory.Achievements]: [],
|
||||
|
||||
@@ -12,7 +12,6 @@ import { userProfileContext } from "@renderer/context";
|
||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import "./user-library-game-card.scss";
|
||||
|
||||
interface UserLibraryGameCardProps {
|
||||
@@ -150,7 +149,7 @@ export function UserLibraryGameCard({
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={steamUrlBuilder.cover(game.objectId)}
|
||||
src={game.coverImageUrl}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
/>
|
||||
|
||||
@@ -36,27 +36,41 @@ export interface DownloadSource {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ShopAssets {
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
title: string;
|
||||
iconUrl: string | null;
|
||||
libraryHeroImageUrl: string;
|
||||
libraryImageUrl: string;
|
||||
logoImageUrl: string;
|
||||
logoPosition: string | null;
|
||||
coverImageUrl: string;
|
||||
}
|
||||
|
||||
export type ShopDetails = SteamAppDetails & {
|
||||
objectId: string;
|
||||
};
|
||||
|
||||
export type ShopDetailsWithAssets = ShopDetails & {
|
||||
assets: ShopAssets | null;
|
||||
};
|
||||
|
||||
export interface TorrentFile {
|
||||
path: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface UserGame {
|
||||
export type UserGame = {
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
title: string;
|
||||
iconUrl: string | null;
|
||||
cover: string;
|
||||
playTimeInSeconds: number;
|
||||
lastTimePlayed: Date | null;
|
||||
unlockedAchievementCount: number;
|
||||
achievementCount: number;
|
||||
achievementsPointsEarnedSum: number;
|
||||
}
|
||||
} & ShopAssets;
|
||||
|
||||
export interface GameRunning {
|
||||
id: string;
|
||||
@@ -100,13 +114,11 @@ export interface UserFriend {
|
||||
profileImageUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
currentGame: {
|
||||
title: string;
|
||||
iconUrl: string;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
sessionDurationInSeconds: number;
|
||||
} | null;
|
||||
currentGame:
|
||||
| (ShopAssets & {
|
||||
sessionDurationInSeconds: number;
|
||||
})
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface UserFriends {
|
||||
@@ -138,10 +150,10 @@ export interface UserRelation {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectId"> {
|
||||
objectId: string;
|
||||
sessionDurationInSeconds: number;
|
||||
}
|
||||
export type UserProfileCurrentGame = GameRunning &
|
||||
ShopAssets & {
|
||||
sessionDurationInSeconds: number;
|
||||
};
|
||||
|
||||
export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS";
|
||||
|
||||
@@ -215,13 +227,12 @@ export interface DownloadSourceValidationResult {
|
||||
export interface GameStats {
|
||||
downloadCount: number;
|
||||
playerCount: number;
|
||||
assets: ShopAssets | null;
|
||||
}
|
||||
|
||||
export interface TrendingGame {
|
||||
export interface TrendingGame extends ShopAssets {
|
||||
description: string | null;
|
||||
uri: string;
|
||||
description: string;
|
||||
background: string;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
export interface UserStatsPercentile {
|
||||
@@ -302,10 +313,25 @@ export interface CatalogueSearchPayload {
|
||||
developers: string[];
|
||||
}
|
||||
|
||||
export interface LibraryGame extends Game {
|
||||
export type CatalogueSearchResult = {
|
||||
id: string;
|
||||
download: Download | null;
|
||||
}
|
||||
tags: string[];
|
||||
genres: string[];
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
title: string;
|
||||
installCount: number;
|
||||
achievementCount: number;
|
||||
shopData: string;
|
||||
} & ShopAssets;
|
||||
|
||||
export type LibraryGame = Game &
|
||||
Partial<ShopAssets> & {
|
||||
id: string;
|
||||
download: Download | null;
|
||||
};
|
||||
|
||||
export * from "./game.types";
|
||||
export * from "./steam.types";
|
||||
|
||||
53
yarn.lock
53
yarn.lock
@@ -2004,6 +2004,42 @@
|
||||
resolved "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.9.0.tgz"
|
||||
integrity sha512-Uk4XrHyfylyfzZN9d8VFjF8FpfYHEyT4sabw+9+oP+GWAJHhPvNPTz6gXvUzJZmoblAvgcTrDslIPjz8zMh76w==
|
||||
|
||||
"@protobuf-ts/plugin-framework@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@protobuf-ts/plugin-framework/-/plugin-framework-2.10.0.tgz#e716a4b7fdb8710527101d145a000e254731abbb"
|
||||
integrity sha512-EuW9irbt+w7Ml1CaAxK6xyl7pSuWVbNy0rsChxJEthMrAVTN5EPdJ3whNWvsRBa+HwRImEl8KHNnRoq/vGOHbg==
|
||||
dependencies:
|
||||
"@protobuf-ts/runtime" "^2.10.0"
|
||||
typescript "^3.9"
|
||||
|
||||
"@protobuf-ts/plugin@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@protobuf-ts/plugin/-/plugin-2.10.0.tgz#dc91af30aeecf2a9debe2d0abf5b2a07a25156c5"
|
||||
integrity sha512-iMX4C4TVfMNRLn2msK0cVg5jmizjtu5FYiy8EK5Lg6EgyR9TVHeK2rzmufWKYM2Pcg1jSwC0cFcXHQnCoeFxUg==
|
||||
dependencies:
|
||||
"@protobuf-ts/plugin-framework" "^2.10.0"
|
||||
"@protobuf-ts/protoc" "^2.10.0"
|
||||
"@protobuf-ts/runtime" "^2.10.0"
|
||||
"@protobuf-ts/runtime-rpc" "^2.10.0"
|
||||
typescript "^3.9"
|
||||
|
||||
"@protobuf-ts/protoc@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@protobuf-ts/protoc/-/protoc-2.10.0.tgz#4a280ead9d5b143b6ef863339980050184bcac49"
|
||||
integrity sha512-S4BtGBh22+uL5E6qLVxV0QNY6tiLVB8QL7RIkvo+KYknipZfSNwubdKy5CPkrwVXzJn4s3cx7bKx1w6BxkBIPg==
|
||||
|
||||
"@protobuf-ts/runtime-rpc@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.10.0.tgz#68de8dcc369e56579569a4deafd394cf4683dc66"
|
||||
integrity sha512-8CS/XPv3+pMK4v8UKhtCdvbS4h9l7aqlteKdRt0/UbIKZ8n0qHj6hX8cBhz2ngvohxCOS0N08zPr9aCLBNhW3Q==
|
||||
dependencies:
|
||||
"@protobuf-ts/runtime" "^2.10.0"
|
||||
|
||||
"@protobuf-ts/runtime@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime/-/runtime-2.10.0.tgz#bc90f632647ff2ae72887546ddf3d193f3f43d98"
|
||||
integrity sha512-ypYwGg9Pn3W/2lZ7/HW60hONGuSdzphvOY8Dq7LeNttymDe0y3LaTUUMRpuGqOT6FfrWEMnfQbyqU8AAreo8wA==
|
||||
|
||||
"@radix-ui/primitive@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
|
||||
@@ -3423,6 +3459,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087"
|
||||
integrity sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==
|
||||
|
||||
"@types/ws@^8.18.1":
|
||||
version "8.18.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
|
||||
integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/yauzl@^2.9.1":
|
||||
version "2.10.3"
|
||||
resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz"
|
||||
@@ -9129,6 +9172,11 @@ typed-array-length@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
reflect.getprototypeof "^1.0.6"
|
||||
|
||||
typescript@^3.9:
|
||||
version "3.9.10"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
|
||||
integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
|
||||
|
||||
typescript@^5.3.3, typescript@^5.4.3:
|
||||
version "5.6.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
|
||||
@@ -9546,6 +9594,11 @@ ws@^8.18.0:
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
|
||||
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
|
||||
|
||||
ws@^8.18.1:
|
||||
version "8.18.1"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb"
|
||||
integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==
|
||||
|
||||
xml-name-validator@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
|
||||
|
||||
Reference in New Issue
Block a user