diff --git a/electron-builder.yml b/electron-builder.yml index ec162530e..64047d7fc 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -69,4 +69,4 @@ publish: owner: hydralauncher repo: hydra electronDownload: - mirror: https://npmmirror.com/mirrors/electron/ + mirror: https://github.com/electron/electron/releases/download/v diff --git a/python_rpc/main.py b/python_rpc/main.py index 99dd0d8c3..b51285ad2 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -4,7 +4,12 @@ from http_downloader import HttpDownloader from profile_image_processor import ProfileImageProcessor from http_multi_link_downloader import HttpMultiLinkDownloader -import libtorrent as lt + +try: + import libtorrent as lt +except Exception as e: + lt = None + print("Warning: libtorrent is not available, torrent features disabled:", e) app = Flask(__name__) @@ -19,7 +24,10 @@ # This can be streamed down from Node downloading_game_id = -1 -torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)}) +if lt is not None: + torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)}) +else: + torrent_session = None if start_download_payload: initial_download = json.loads(urllib.parse.unquote(start_download_payload)) @@ -34,12 +42,15 @@ except Exception as e: print("Error starting multi-link download", e) elif initial_download['url'].startswith('magnet'): - torrent_downloader = TorrentDownloader(torrent_session) - downloads[initial_download['game_id']] = torrent_downloader - try: - torrent_downloader.start_download(initial_download['url'], initial_download['save_path']) - except Exception as e: - print("Error starting torrent download", e) + if lt is None: + print("Warning: libtorrent is not available, skipping initial torrent download for", initial_download['game_id']) + else: + torrent_downloader = TorrentDownloader(torrent_session) + downloads[initial_download['game_id']] = torrent_downloader + try: + torrent_downloader.start_download(initial_download['url'], initial_download['save_path']) + except Exception as e: + print("Error starting torrent download", e) else: http_downloader = HttpDownloader() downloads[initial_download['game_id']] = http_downloader @@ -49,14 +60,17 @@ print("Error starting http download", e) if start_seeding_payload: - initial_seeding = json.loads(urllib.parse.unquote(start_seeding_payload)) - for seed in initial_seeding: - torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) - downloads[seed['game_id']] = torrent_downloader - try: - torrent_downloader.start_download(seed['url'], seed['save_path']) - except Exception as e: - print("Error starting seeding", e) + if lt is None: + print("Warning: libtorrent is not available, skipping initial seeding payload") + else: + initial_seeding = json.loads(urllib.parse.unquote(start_seeding_payload)) + for seed in initial_seeding: + torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) + downloads[seed['game_id']] = torrent_downloader + try: + torrent_downloader.start_download(seed['url'], seed['save_path']) + except Exception as e: + print("Error starting seeding", e) def validate_rpc_password(): """Middleware to validate RPC password.""" @@ -153,11 +167,8 @@ def profile_image(): data = request.get_json() image_path = data.get('image_path') - # use webp as default value for target_extension - target_extension = data.get('target_extension') or 'webp' - try: - processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension) + processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path) return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200 except Exception as e: return jsonify({"error": str(e)}), 400 @@ -217,9 +228,12 @@ def action(): if downloader: downloader.cancel_download() elif action == 'resume_seeding': - torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) - downloads[game_id] = torrent_downloader - torrent_downloader.start_download(data['url'], data['save_path']) + if lt is None: + print("Warning: libtorrent is not available, cannot resume seeding for", game_id) + else: + torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode) + downloads[game_id] = torrent_downloader + torrent_downloader.start_download(data['url'], data['save_path']) elif action == 'pause_seeding': downloader = downloads.get(game_id) if downloader: diff --git a/python_rpc/profile_image_processor.py b/python_rpc/profile_image_processor.py index eac8c32a6..45ba51602 100644 --- a/python_rpc/profile_image_processor.py +++ b/python_rpc/profile_image_processor.py @@ -4,7 +4,7 @@ class ProfileImageProcessor: @staticmethod - def get_parsed_image_data(image_path, target_extension): + def get_parsed_image_data(image_path): Image.MAX_IMAGE_PIXELS = 933120000 image = Image.open(image_path) @@ -16,7 +16,7 @@ def get_parsed_image_data(image_path, target_extension): return image_path, mime_type else: new_uuid = str(uuid.uuid4()) - new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension + new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp" image.save(new_image_path) new_image = Image.open(new_image_path) @@ -26,5 +26,5 @@ def get_parsed_image_data(image_path, target_extension): @staticmethod - def process_image(image_path, target_extension): - return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension) + def process_image(image_path): + return ProfileImageProcessor.get_parsed_image_data(image_path) diff --git a/python_rpc/torrent_downloader.py b/python_rpc/torrent_downloader.py index 8de8764ee..db8704ae3 100644 --- a/python_rpc/torrent_downloader.py +++ b/python_rpc/torrent_downloader.py @@ -1,7 +1,20 @@ -import libtorrent as lt +try: + import libtorrent as lt +except Exception as e: + lt = None + print("Warning: libtorrent is not available, torrent features disabled:", e) + class TorrentDownloader: - def __init__(self, torrent_session, flags = lt.torrent_flags.auto_managed): + def __init__(self, torrent_session, flags=None): + if lt is None: + # Torrent functionality is disabled when libtorrent is missing. + # The caller should avoid using TorrentDownloader in this case. + raise RuntimeError("libtorrent is not available; TorrentDownloader cannot be used") + + if flags is None: + flags = lt.torrent_flags.auto_managed + self.torrent_handle = None self.session = torrent_session self.flags = flags diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0c..e23b280fa 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -287,6 +287,16 @@ "launch_options": "Launch Options", "launch_options_description": "Advanced users may choose to enter modifications to their launch options (experimental feature)", "launch_options_placeholder": "No parameter specified", + "collections_section_title": "Collections & tags", + "collections_section_description": "Organize this game with custom tags and a play status", + "collections_tags_label": "Tags", + "collections_tags_placeholder": "Add tags separated by commas (e.g. To beat later, Co-op, Horror)", + "play_status": "Status", + "play_status_none": "No status", + "play_status_not_installed": "Not installed", + "play_status_installed": "Installed", + "play_status_completed": "Completed", + "play_status_abandoned": "Abandoned", "no_download_option_info": "No information available", "backup_deletion_failed": "Failed to delete backup", "max_number_of_artifacts_reached": "Maximum number of backups reached for this game", @@ -467,6 +477,14 @@ "found_download_option_zero": "No download option found", "found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_other": "Found {{countFormatted}} download options", + "export_download_sources": "Export sources", + "import_download_sources": "Import sources", + "download_sources_json_filter": "Download sources JSON", + "download_sources_export_success": "Exported {{countFormatted}} sources", + "download_sources_export_failed": "Failed to export download sources", + "download_sources_import_success": "Imported {{countFormatted}} sources", + "download_sources_import_skipped": "Skipped {{countFormatted}} sources", + "download_sources_import_failed": "Failed to import download sources", "import": "Import", "importing": "Importing...", "public": "Public", @@ -730,7 +748,9 @@ "manual_playtime_tooltip": "This playtime has been manually updated", "all_games": "All Games", "recently_played": "Recently Played", - "favorites": "Favorites" + "favorites": "Favorites", + "play_status_all": "All statuses", + "collections_tags_filter_placeholder": "Filter by tag" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b831ff2e4..e3a819bd2 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -93,6 +93,7 @@ }, "header": { "search": "Поиск", + "search_library": "Поиск по библиотеке", "home": "Главная", "catalogue": "Каталог", "downloads": "Загрузки", @@ -285,6 +286,16 @@ "launch_options": "Параметры запуска", "launch_options_description": "Опытные пользователи могут внести изменения в параметры запуска", "launch_options_placeholder": "Параметр не указан ", + "collections_section_title": "Коллекции и теги", + "collections_section_description": "Организуйте игру с помощью пользовательских тегов и статуса прохождения", + "collections_tags_label": "Теги", + "collections_tags_placeholder": "Добавьте теги через запятую (например: Пройти позже, Кооператив, Хорроры)", + "play_status": "Статус", + "play_status_none": "Без статуса", + "play_status_not_installed": "Не установлена", + "play_status_installed": "Установлена", + "play_status_completed": "Пройдена", + "play_status_abandoned": "Брошена", "no_download_option_info": "Информация недоступна", "backup_deletion_failed": "Не удалось удалить резервную копию", "max_number_of_artifacts_reached": "Достигнуто максимальное количество резервных копий для этой игры", @@ -353,6 +364,8 @@ "caption": "Субтитры", "audio": "Аудио", "filter_by_source": "Фильтр по источнику", + "sort_by_size": "Сортировать по размеру", + "sort_by_date": "Сортировать по дате", "no_repacks_found": "Источники для этой игры не найдены", "show": "Показать", "hide": "Скрыть", @@ -465,6 +478,14 @@ "found_download_option_zero": "Не найдено вариантов загрузки", "found_download_option_one": "Найден {{countFormatted}} вариант загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", + "export_download_sources": "Экспортировать источники", + "import_download_sources": "Импортировать источники", + "download_sources_json_filter": "JSON источников загрузки", + "download_sources_export_success": "Экспортировано источников: {{countFormatted}}", + "download_sources_export_failed": "Не удалось экспортировать источники загрузки", + "download_sources_import_success": "Импортировано источников: {{countFormatted}}", + "download_sources_import_skipped": "Пропущено источников: {{countFormatted}}", + "download_sources_import_failed": "Не удалось импортировать источники загрузки", "import": "Импортировать", "importing": "Импортируется...", "public": "Публичный", @@ -755,6 +776,8 @@ "manual_playtime_tooltip": "Время игры было обновлено вручную", "all_games": "Все игры", "recently_played": "Недавно сыгранные", - "favorites": "Избранное" + "favorites": "Избранное", + "play_status_all": "Все статусы", + "collections_tags_filter_placeholder": "Фильтр по тегу" } } diff --git a/src/main/constants.ts b/src/main/constants.ts index 3c4c10e52..d484de1f7 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,59 +1,59 @@ import { app } from "electron"; import path from "node:path"; -import { SystemPath } from "./services/system-path"; +import { SystemPath } from "@main/services/system-path"; -export const defaultDownloadsPath = SystemPath.getPath("downloads"); +const apiUrl = import.meta.env.MAIN_VITE_API_URL ?? ""; -export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); +export const isStaging = () => apiUrl.includes("staging"); -export const windowsStartMenuPath = path.join( - SystemPath.getPath("appData"), - "Microsoft", - "Windows", - "Start Menu", - "Programs" -); +export const defaultDownloadsPath = () => SystemPath.getPath("downloads"); -export const publicProfilePath = "C:/Users/Public"; +export const windowsStartMenuPath = () => + path.join( + SystemPath.getPath("appData"), + "Microsoft", + "Windows", + "Start Menu", + "Programs" + ); -export const levelDatabasePath = path.join( - SystemPath.getPath("userData"), - `hydra-db${isStaging ? "-staging" : ""}` -); +export const publicProfilePath = () => "C:/Users/Public"; -export const commonRedistPath = path.join( - SystemPath.getPath("userData"), - "CommonRedist" -); +export const levelDatabasePath = () => + path.join( + SystemPath.getPath("userData"), + `hydra-db${isStaging() ? "-staging" : ""}` + ); -export const logsPath = path.join( - SystemPath.getPath("userData"), - `logs${isStaging ? "-staging" : ""}` -); +export const commonRedistPath = () => + path.join(SystemPath.getPath("userData"), "CommonRedist"); -export const achievementSoundPath = app.isPackaged - ? path.join(process.resourcesPath, "achievement.wav") - : path.join(__dirname, "..", "..", "resources", "achievement.wav"); +export const logsPath = () => + path.join(SystemPath.getPath("userData"), `logs${isStaging() ? "-staging" : ""}`); -export const backupsPath = path.join(SystemPath.getPath("userData"), "Backups"); +export const achievementSoundPath = () => + app.isPackaged + ? path.join(process.resourcesPath, "achievement.wav") + : path.join(__dirname, "..", "..", "resources", "achievement.wav"); -export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); +export const backupsPath = () => + path.join(SystemPath.getPath("userData"), "Backups"); -export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); +export const appVersion = () => + app.getVersion() + (isStaging() ? "-staging" : ""); -export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes"); +export const ASSETS_PATH = () => + path.join(SystemPath.getPath("userData"), "Assets"); + +export const THEMES_PATH = () => + path.join(SystemPath.getPath("userData"), "themes"); export const MAIN_LOOP_INTERVAL = 2000; export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15; -export const DECKY_PLUGINS_LOCATION = path.join( - SystemPath.getPath("home"), - "homebrew", - "plugins" -); +export const DECKY_PLUGINS_LOCATION = () => + path.join(SystemPath.getPath("home"), "homebrew", "plugins"); -export const HYDRA_DECKY_PLUGIN_LOCATION = path.join( - DECKY_PLUGINS_LOCATION, - "Hydra" -); +export const HYDRA_DECKY_PLUGIN_LOCATION = () => + path.join(DECKY_PLUGINS_LOCATION(), "Hydra"); diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts index 99edf3df7..5743b62b4 100644 --- a/src/main/events/cloud-save/download-game-artifact.ts +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -73,8 +73,8 @@ const restoreLudusaviBackup = ( addWinePrefixToWindowsPath(userProfilePath, winePrefixPath) ) .replace( - publicProfilePath, - addWinePrefixToWindowsPath(publicProfilePath, winePrefixPath) + publicProfilePath(), + addWinePrefixToWindowsPath(publicProfilePath(), winePrefixPath) ); logger.info(`Moving ${sourcePath} to ${destinationPath}`); @@ -112,7 +112,7 @@ const downloadGameArtifact = async ( }>(`/profile/games/artifacts/${gameArtifactId}/download`); const zipLocation = path.join(SystemPath.getPath("userData"), objectKey); - const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + const backupPath = path.join(backupsPath(), `${shop}-${objectId}`); if (fs.existsSync(backupPath)) { fs.rmSync(backupPath, { diff --git a/src/main/events/download-sources/export-download-sources.ts b/src/main/events/download-sources/export-download-sources.ts new file mode 100644 index 000000000..7b345d7a5 --- /dev/null +++ b/src/main/events/download-sources/export-download-sources.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; + +import { downloadSourcesSublevel } from "@main/level"; +import { logger } from "@main/services"; +import type { + DownloadSource, + DownloadSourcesConfig, + DownloadSourcesExportResult, +} from "@types"; +import { registerEvent } from "../register-event"; + +const CURRENT_CONFIG_VERSION = 1; + +const normalizeSource = (source: DownloadSource): DownloadSource => { + return { + ...source, + downloadCount: Number.isFinite(source.downloadCount) + ? source.downloadCount + : 0, + createdAt: + typeof source.createdAt === "string" + ? source.createdAt + : new Date().toISOString(), + }; +}; + +const exportDownloadSources = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + if (!filePath) { + throw new Error("FILE_PATH_REQUIRED"); + } + + const normalizedPath = filePath.endsWith(".json") + ? filePath + : `${filePath}.json`; + + const sources = await downloadSourcesSublevel.values().all(); + + const config: DownloadSourcesConfig = { + version: CURRENT_CONFIG_VERSION, + exportedAt: new Date().toISOString(), + sources: sources.map(normalizeSource), + }; + + try { + await fs.promises.writeFile( + normalizedPath, + JSON.stringify(config, null, 2), + "utf-8" + ); + } catch (error) { + logger.error("Failed to export download sources", error); + throw error; + } + + return { + exported: config.sources.length, + }; +}; + +registerEvent("exportDownloadSources", exportDownloadSources); diff --git a/src/main/events/download-sources/import-download-sources.ts b/src/main/events/download-sources/import-download-sources.ts new file mode 100644 index 000000000..cbc11be05 --- /dev/null +++ b/src/main/events/download-sources/import-download-sources.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import { randomUUID } from "node:crypto"; + +import { downloadSourcesSublevel } from "@main/level"; +import { logger } from "@main/services"; +import type { + DownloadSource, + DownloadSourcesConfig, + DownloadSourcesImportResult, +} from "@types"; +import { DownloadSourceStatus } from "@shared"; +import { registerEvent } from "../register-event"; + +const SUPPORTED_CONFIG_VERSION = 1; +const DOWNLOAD_SOURCE_STATUS_SET = new Set( + Object.values(DownloadSourceStatus) as string[] +); + +type RawDownloadSource = Partial & { + id?: string; + url?: string; + name?: string; +}; + +const sanitizeDownloadCount = (value: unknown): number => { + const parsed = Number(value); + if (Number.isNaN(parsed) || !isFinite(parsed) || parsed < 0) { + return 0; + } + + return Math.floor(parsed); +}; + +const sanitizeDate = (value: unknown): string => { + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) { + return new Date(value).toISOString(); + } + + return new Date().toISOString(); +}; + +const normalizeSource = (raw: RawDownloadSource): DownloadSource => { + const url = typeof raw.url === "string" ? raw.url.trim() : ""; + + if (!url) { + throw new Error("INVALID_URL"); + } + + const initialId = typeof raw.id === "string" ? raw.id.trim() : ""; + const id = initialId.length ? initialId : randomUUID(); + + const name = + typeof raw.name === "string" && raw.name.trim().length + ? raw.name.trim() + : url; + + const rawStatus = typeof raw.status === "string" ? raw.status : undefined; + const status = + rawStatus && + DOWNLOAD_SOURCE_STATUS_SET.has(rawStatus as DownloadSourceStatus) + ? (rawStatus as DownloadSourceStatus) + : DownloadSourceStatus.PendingMatching; + + const fingerprint = + typeof raw.fingerprint === "string" && raw.fingerprint.trim().length + ? raw.fingerprint.trim() + : undefined; + + return { + id, + name, + url, + status, + downloadCount: sanitizeDownloadCount(raw.downloadCount), + fingerprint, + isRemote: raw.isRemote ? true : undefined, + createdAt: sanitizeDate(raw.createdAt), + }; +}; + +const importDownloadSources = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + if (!filePath) { + throw new Error("FILE_PATH_REQUIRED"); + } + + let fileContent: string; + + try { + fileContent = await fs.promises.readFile(filePath, "utf-8"); + } catch (error) { + logger.error("Failed to read download sources file", error); + throw new Error("DOWNLOAD_SOURCES_FILE_READ_FAILED"); + } + + let parsedConfig: DownloadSourcesConfig; + + try { + parsedConfig = JSON.parse(fileContent) as DownloadSourcesConfig; + } catch (error) { + logger.error("Invalid download sources configuration file", error); + throw new Error("DOWNLOAD_SOURCES_INVALID_JSON"); + } + + if (!parsedConfig || typeof parsedConfig !== "object") { + throw new Error("DOWNLOAD_SOURCES_INVALID_CONFIG"); + } + + if ( + typeof parsedConfig.version !== "number" || + Number.isNaN(parsedConfig.version) + ) { + throw new Error("DOWNLOAD_SOURCES_INVALID_VERSION"); + } + + if (parsedConfig.version > SUPPORTED_CONFIG_VERSION) { + throw new Error("DOWNLOAD_SOURCES_UNSUPPORTED_VERSION"); + } + + if (!Array.isArray(parsedConfig.sources)) { + throw new Error("DOWNLOAD_SOURCES_INVALID_SOURCES"); + } + + const existingSources = await downloadSourcesSublevel.values().all(); + + const existingUrls = new Set( + existingSources.map((source) => source.url.trim().toLowerCase()) + ); + const existingIds = new Set(existingSources.map((source) => source.id)); + + const importedIds = new Set(); + const importedUrls = new Set(); + + let imported = 0; + let skipped = 0; + + for (const rawSource of parsedConfig.sources as RawDownloadSource[]) { + try { + const normalized = normalizeSource(rawSource); + const normalizedUrl = normalized.url.toLowerCase(); + + if (existingUrls.has(normalizedUrl) || importedUrls.has(normalizedUrl)) { + skipped += 1; + continue; + } + + let sourceId = normalized.id; + + if (existingIds.has(sourceId) || importedIds.has(sourceId)) { + sourceId = randomUUID(); + } + + const sourceToPersist: DownloadSource = { + ...normalized, + id: sourceId, + }; + + await downloadSourcesSublevel.put(sourceId, sourceToPersist); + + existingIds.add(sourceId); + existingUrls.add(normalizedUrl); + importedIds.add(sourceId); + importedUrls.add(normalizedUrl); + imported += 1; + } catch (error) { + skipped += 1; + logger.error("Failed to import download source", error); + } + } + + return { + imported, + skipped, + }; +}; + +registerEvent("importDownloadSources", importDownloadSources); diff --git a/src/main/events/helpers/get-downloads-path.ts b/src/main/events/helpers/get-downloads-path.ts index 0403095ff..a4748f645 100644 --- a/src/main/events/helpers/get-downloads-path.ts +++ b/src/main/events/helpers/get-downloads-path.ts @@ -12,5 +12,5 @@ export const getDownloadsPath = async () => { if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; - return defaultDownloadsPath; + return defaultDownloadsPath(); }; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 2720d3ceb..69c9d1f91 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -13,6 +13,7 @@ import "./library/update-game-custom-assets"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/toggle-game-pin"; +import "./library/update-game-collections"; import "./library/create-game-shortcut"; import "./library/close-game"; import "./library/delete-game-folder"; @@ -41,6 +42,7 @@ import "./library/copy-custom-game-asset"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; +import "./misc/show-save-dialog"; import "./misc/show-item-in-folder"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; @@ -102,10 +104,12 @@ import "./themes/get-theme-sound-data-url"; import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; +import "./download-sources/export-download-sources"; +import "./download-sources/import-download-sources"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); -ipcMain.handle("getVersion", () => appVersion); -ipcMain.handle("isStaging", () => isStaging); +ipcMain.handle("getVersion", () => appVersion()); +ipcMain.handle("isStaging", () => isStaging()); ipcMain.handle("isPortableVersion", () => isPortableVersion()); -ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); +ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath()); diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index 6a90087e8..125710bd3 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -1,7 +1,7 @@ import { registerEvent } from "../register-event"; import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; import { randomUUID } from "node:crypto"; -import type { GameShop } from "@types"; +import type { Game, GameShop } from "@types"; const addCustomGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -41,7 +41,7 @@ const addCustomGameToLibrary = async ( }; await gamesShopAssetsSublevel.put(gameKey, assets); - const game = { + const game: Game = { title, iconUrl: iconUrl || null, logoImageUrl: logoImageUrl || null, @@ -57,6 +57,7 @@ const addCustomGameToLibrary = async ( favorite: false, automaticCloudSync: false, hasManuallyUpdatedPlaytime: false, + tags: [], }; await gamesSublevel.put(gameKey, game); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 4fdeae304..d79503ac0 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -38,6 +38,7 @@ const addGameToLibrary = async ( isDeleted: false, playTimeInMilliseconds: 0, lastTimePlayed: null, + tags: [], }; await gamesSublevel.put(gameKey, game); diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts index d1d77e9ff..86da63052 100644 --- a/src/main/events/library/cleanup-unused-assets.ts +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -4,7 +4,7 @@ import path from "path"; import { ASSETS_PATH } from "@main/constants"; const getCustomGamesAssetsPath = () => { - return path.join(ASSETS_PATH, "custom-games"); + return path.join(ASSETS_PATH(), "custom-games"); }; const getAllCustomGameAssets = async (): Promise => { diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts index 1f5aea0f5..d4e078c2c 100644 --- a/src/main/events/library/copy-custom-game-asset.ts +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -13,11 +13,13 @@ const copyCustomGameAsset = async ( throw new Error("Source file does not exist"); } - if (!fs.existsSync(ASSETS_PATH)) { - fs.mkdirSync(ASSETS_PATH, { recursive: true }); + const assetsRoot = ASSETS_PATH(); + + if (!fs.existsSync(assetsRoot)) { + fs.mkdirSync(assetsRoot, { recursive: true }); } - const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); + const customGamesAssetsPath = path.join(assetsRoot, "custom-games"); if (!fs.existsSync(customGamesAssetsPath)) { fs.mkdirSync(customGamesAssetsPath, { recursive: true }); } diff --git a/src/main/events/library/create-steam-shortcut.ts b/src/main/events/library/create-steam-shortcut.ts index d5434d7fe..8ce34d928 100644 --- a/src/main/events/library/create-steam-shortcut.ts +++ b/src/main/events/library/create-steam-shortcut.ts @@ -43,7 +43,7 @@ const downloadAssetsFromSteam = async ( objectId: string, assets: ShopAssets | null ) => { - const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`); + const gameAssetsPath = path.join(ASSETS_PATH(), `${shop}-${objectId}`); return await Promise.all([ downloadAsset(path.join(gameAssetsPath, "icon.ico"), assets?.iconUrl), diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 9fb3416b2..c434f6d3d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -2,7 +2,6 @@ import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; import { downloadsSublevel, - gameAchievementsSublevel, gamesShopAssetsSublevel, gamesSublevel, } from "@main/level"; @@ -19,20 +18,11 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); - let unlockedAchievementCount = game.unlockedAchievementCount ?? 0; - - if (!game.unlockedAchievementCount) { - const achievements = await gameAchievementsSublevel.get(key); - - unlockedAchievementCount = - achievements?.unlockedAchievements.length ?? 0; - } - return { id: key, ...game, download: download ?? null, - unlockedAchievementCount, + unlockedAchievementCount: game.unlockedAchievementCount ?? 0, achievementCount: game.achievementCount ?? 0, // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, diff --git a/src/main/events/library/update-game-collections.ts b/src/main/events/library/update-game-collections.ts new file mode 100644 index 000000000..c5b53e837 --- /dev/null +++ b/src/main/events/library/update-game-collections.ts @@ -0,0 +1,43 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import type { GamePlayStatus, GameShop } from "@types"; + +interface UpdateGameCollectionsPayload { + shop: GameShop; + objectId: string; + tags?: string[]; + playStatus?: GamePlayStatus | null; +} + +const sanitizeTags = (rawTags?: string[]): string[] => { + if (!rawTags || !Array.isArray(rawTags)) return []; + + const normalized = rawTags + .map((tag) => (typeof tag === "string" ? tag.trim() : "")) + .filter((tag) => tag.length > 0) + .slice(0, 20); + + return Array.from(new Set(normalized)); +}; + +const updateGameCollections = async ( + _event: Electron.IpcMainInvokeEvent, + payload: UpdateGameCollectionsPayload +) => { + const { shop, objectId, tags, playStatus } = payload; + + const gameKey = levelKeys.game(shop, objectId); + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + const nextTags = sanitizeTags(tags ?? game.tags); + + await gamesSublevel.put(gameKey, { + ...game, + tags: nextTags, + playStatus: playStatus ?? game.playStatus ?? undefined, + }); +}; + +registerEvent("updateGameCollections", updateGameCollections); diff --git a/src/main/events/misc/check-homebrew-folder-exists.ts b/src/main/events/misc/check-homebrew-folder-exists.ts index 32e097545..42fa12b6e 100644 --- a/src/main/events/misc/check-homebrew-folder-exists.ts +++ b/src/main/events/misc/check-homebrew-folder-exists.ts @@ -6,7 +6,7 @@ import path from "node:path"; const checkHomebrewFolderExists = async ( _event: Electron.IpcMainInvokeEvent ): Promise => { - const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION); + const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION()); return fs.existsSync(homebrewPath); }; diff --git a/src/main/events/misc/get-hydra-decky-plugin-info.ts b/src/main/events/misc/get-hydra-decky-plugin-info.ts index 430bd6915..e30eb625c 100644 --- a/src/main/events/misc/get-hydra-decky-plugin-info.ts +++ b/src/main/events/misc/get-hydra-decky-plugin-info.ts @@ -33,29 +33,26 @@ const getHydraDeckyPluginInfo = async ( } // Check if plugin folder exists - if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION())) { logger.log("Hydra Decky plugin not installed"); return { installed: false, version: null, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), outdated: true, expectedVersion, }; } // Check if package.json exists - const packageJsonPath = path.join( - HYDRA_DECKY_PLUGIN_LOCATION, - "package.json" - ); + const packageJsonPath = path.join(HYDRA_DECKY_PLUGIN_LOCATION(), "package.json"); if (!fs.existsSync(packageJsonPath)) { logger.log("Hydra Decky plugin package.json not found"); return { installed: false, version: null, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), outdated: true, expectedVersion, }; @@ -75,7 +72,7 @@ const getHydraDeckyPluginInfo = async ( return { installed: true, version, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), outdated, expectedVersion, }; @@ -84,7 +81,7 @@ const getHydraDeckyPluginInfo = async ( return { installed: false, version: null, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), outdated: true, expectedVersion: null, }; diff --git a/src/main/events/misc/install-hydra-decky-plugin.ts b/src/main/events/misc/install-hydra-decky-plugin.ts index e14ea2ed6..0c7ba96e6 100644 --- a/src/main/events/misc/install-hydra-decky-plugin.ts +++ b/src/main/events/misc/install-hydra-decky-plugin.ts @@ -20,7 +20,7 @@ const installHydraDeckyPlugin = async ( logger.log("Plugin installed successfully"); return { success: true, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), currentVersion: result.currentVersion, expectedVersion: result.expectedVersion, }; @@ -28,7 +28,7 @@ const installHydraDeckyPlugin = async ( logger.error("Failed to install plugin"); return { success: false, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), currentVersion: result.currentVersion, expectedVersion: result.expectedVersion, error: "Plugin installation failed", @@ -39,7 +39,7 @@ const installHydraDeckyPlugin = async ( logger.error("Failed to install plugin:", error); return { success: false, - path: HYDRA_DECKY_PLUGIN_LOCATION, + path: HYDRA_DECKY_PLUGIN_LOCATION(), currentVersion: null, expectedVersion: "unknown", error: errorMessage, diff --git a/src/main/events/misc/show-save-dialog.ts b/src/main/events/misc/show-save-dialog.ts new file mode 100644 index 000000000..38b12db6d --- /dev/null +++ b/src/main/events/misc/show-save-dialog.ts @@ -0,0 +1,16 @@ +import { dialog } from "electron"; +import { WindowManager } from "@main/services"; +import { registerEvent } from "../register-event"; + +const showSaveDialog = async ( + _event: Electron.IpcMainInvokeEvent, + options: Electron.SaveDialogOptions +) => { + if (WindowManager.mainWindow) { + return dialog.showSaveDialog(WindowManager.mainWindow, options); + } + + throw new Error("Main window is not available"); +}; + +registerEvent("showSaveDialog", showSaveDialog); diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index bec17cb68..6166f7f89 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -1,20 +1,16 @@ import { registerEvent } from "../register-event"; import { PythonRPC } from "@main/services/python-rpc"; -const processProfileImageEvent = async ( +const processProfileImage = async ( _event: Electron.IpcMainInvokeEvent, path: string ) => { - return processProfileImage(path, "webp"); -}; - -export const processProfileImage = async (path: string, extension?: string) => { return PythonRPC.rpc .post<{ imagePath: string; mimeType: string; - }>("/profile-image", { image_path: path, target_extension: extension }) + }>("/profile-image", { image_path: path }) .then((response) => response.data); }; -registerEvent("processProfileImage", processProfileImageEvent); +registerEvent("processProfileImage", processProfileImage); diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts index a86034269..19c1375d1 100644 --- a/src/main/events/themes/remove-theme-achievement-sound.ts +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -15,7 +15,7 @@ const removeThemeAchievementSound = async ( } const themeDir = getThemePath(themeId, theme.name); - const legacyThemeDir = path.join(THEMES_PATH, themeId); + const legacyThemeDir = path.join(THEMES_PATH(), themeId); const removeFromDir = async (dir: string) => { if (!fs.existsSync(dir)) { diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 664dbd788..c50335f5f 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -51,10 +51,10 @@ export const getThemePath = (themeId: string, themeName?: string): string => { if (themeName) { const sanitizedName = sanitizeFolderName(themeName); if (sanitizedName) { - return path.join(THEMES_PATH, sanitizedName); + return path.join(THEMES_PATH(), sanitizedName); } } - return path.join(THEMES_PATH, themeId); + return path.join(THEMES_PATH(), themeId); }; export const getThemeSoundPath = ( @@ -62,7 +62,7 @@ export const getThemeSoundPath = ( themeName?: string ): string | null => { const themeDir = getThemePath(themeId, themeName); - const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null; + const legacyThemeDir = themeName ? path.join(THEMES_PATH(), themeId) : null; const checkDir = (dir: string): string | null => { if (!fs.existsSync(dir)) { diff --git a/src/main/level/level.ts b/src/main/level/level.ts index 9819efad0..f188fcaed 100644 --- a/src/main/level/level.ts +++ b/src/main/level/level.ts @@ -1,6 +1,6 @@ import { levelDatabasePath } from "@main/constants"; import { ClassicLevel } from "classic-level"; -export const db = new ClassicLevel(levelDatabasePath, { +export const db = new ClassicLevel(levelDatabasePath(), { valueEncoding: "json", }); diff --git a/src/main/main.ts b/src/main/main.ts index 1cadcebd8..976cabd5e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -24,6 +24,10 @@ import { migrateDownloadSources } from "./helpers/migrate-download-sources"; export const loadState = async () => { await Lock.acquireLock(); + if (db.status !== "open") { + await db.open(); + } + const userPreferences = await db.get( levelKeys.userPreferences, { diff --git a/src/main/services/cloud-sync.ts b/src/main/services/cloud-sync.ts index 200a5ee37..976fcbce8 100644 --- a/src/main/services/cloud-sync.ts +++ b/src/main/services/cloud-sync.ts @@ -73,7 +73,7 @@ export class CloudSync { objectId: string, winePrefix: string | null ) { - const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + const backupPath = path.join(backupsPath(), `${shop}-${objectId}`); // Remove existing backup if (fs.existsSync(backupPath)) { @@ -86,7 +86,7 @@ export class CloudSync { await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix); - const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`); + const tarLocation = path.join(backupsPath(), `${crypto.randomUUID()}.tar`); await tar.create( { diff --git a/src/main/services/common-redist-manager.ts b/src/main/services/common-redist-manager.ts index 862a005f3..4b1c69b11 100644 --- a/src/main/services/common-redist-manager.ts +++ b/src/main/services/common-redist-manager.ts @@ -63,7 +63,7 @@ export class CommonRedistManager { }); cp.exec( - path.join(commonRedistPath, "install.bat"), + path.join(commonRedistPath(), "install.bat"), { windowsHide: true, }, @@ -77,19 +77,19 @@ export class CommonRedistManager { public static async canInstallCommonRedist() { return this.redistributables.every((redist) => { - const filePath = path.join(commonRedistPath, redist); + const filePath = path.join(commonRedistPath(), redist); return fs.existsSync(filePath); }); } public static async downloadCommonRedist() { - if (!fs.existsSync(commonRedistPath)) { - await fs.promises.mkdir(commonRedistPath, { recursive: true }); + if (!fs.existsSync(commonRedistPath())) { + await fs.promises.mkdir(commonRedistPath(), { recursive: true }); } for (const redist of this.redistributables) { - const filePath = path.join(commonRedistPath, redist); + const filePath = path.join(commonRedistPath(), redist); if (fs.existsSync(filePath) && redist !== "install.bat") { continue; diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts index 4dc1fdada..60a6ff206 100644 --- a/src/main/services/decky-plugin.ts +++ b/src/main/services/decky-plugin.ts @@ -42,7 +42,7 @@ export class DeckyPlugin { } private static getPackageJsonPath(): string { - return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json"); + return path.join(HYDRA_DECKY_PLUGIN_LOCATION(), "package.json"); } private static async downloadPlugin(): Promise { @@ -93,12 +93,12 @@ export class DeckyPlugin { private static needsSudo(): boolean { try { - if (fs.existsSync(DECKY_PLUGINS_LOCATION)) { - fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK); + if (fs.existsSync(DECKY_PLUGINS_LOCATION())) { + fs.accessSync(DECKY_PLUGINS_LOCATION(), fs.constants.W_OK); return false; } - const parentDir = path.dirname(DECKY_PLUGINS_LOCATION); + const parentDir = path.dirname(DECKY_PLUGINS_LOCATION()); if (fs.existsSync(parentDir)) { fs.accessSync(parentDir, fs.constants.W_OK); return false; @@ -127,7 +127,7 @@ export class DeckyPlugin { const sourcePath = path.join(extractPath, "Hydra"); return new Promise((resolve, reject) => { - const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`; + const command = `mkdir -p "${DECKY_PLUGINS_LOCATION()}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION()}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION()}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION()}"`; sudo.exec( command, @@ -155,18 +155,18 @@ export class DeckyPlugin { const sourcePath = path.join(extractPath, "Hydra"); - if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) { - await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true }); + if (!fs.existsSync(DECKY_PLUGINS_LOCATION())) { + await fs.promises.mkdir(DECKY_PLUGINS_LOCATION(), { recursive: true }); } - if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { - await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, { + if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION())) { + await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION(), { recursive: true, force: true, }); } - await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, { + await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION(), { recursive: true, }); @@ -219,7 +219,7 @@ export class DeckyPlugin { } public static async checkAndUpdateIfOutdated(): Promise { - if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION())) { logger.log("Hydra Decky plugin not installed, skipping update check"); return; } @@ -264,7 +264,7 @@ export class DeckyPlugin { try { const releaseInfo = await this.getDeckyReleaseInfo(); - if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) { + if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION())) { logger.log("Hydra Decky plugin folder not found, installing..."); try { diff --git a/src/main/services/download/torbox.ts b/src/main/services/download/torbox.ts index 601557040..a42a4a570 100644 --- a/src/main/services/download/torbox.ts +++ b/src/main/services/download/torbox.ts @@ -19,7 +19,7 @@ export class TorBoxClient { baseURL: this.baseURL, headers: { Authorization: `Bearer ${apiToken}`, - "User-Agent": `Hydra/${appVersion}`, + "User-Agent": `Hydra/${appVersion()}`, }, }); } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4ad..5f63157c2 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -126,7 +126,7 @@ export class HydraApi { static async setupApi() { this.instance = axios.create({ baseURL: import.meta.env.MAIN_VITE_API_URL, - headers: { "User-Agent": `Hydra Launcher v${appVersion}` }, + headers: { "User-Agent": `Hydra Launcher v${appVersion()}` }, }); if (this.ADD_LOG_INTERCEPTOR) { @@ -160,34 +160,53 @@ export class HydraApi { }, (error) => { logger.error(" ---- RESPONSE ERROR -----"); - const { config } = error; - const data = JSON.parse(config.data ?? null); + const config = error?.config; + const sanitize = (value: unknown) => { + if (Array.isArray(value)) return value; + if (value && typeof value === "object") { + return omit(value as Record, [ + "accessToken", + "refreshToken", + ]); + } + + return value; + }; + + let parsedData: unknown = config?.data; + if (typeof config?.data === "string") { + try { + parsedData = JSON.parse(config.data); + } catch { + parsedData = config.data; + } + } logger.error( - config.method, - config.baseURL, - config.url, - omit(config.headers, [ + config?.method, + config?.baseURL, + config?.url, + omit(config?.headers ?? {}, [ "accessToken", "refreshToken", "Authorization", ]), - Array.isArray(data) - ? data - : omit(data, ["accessToken", "refreshToken"]) + sanitize(parsedData) ); - if (error.response) { + + const response = error?.response; + if (response) { logger.error( "Response error:", - error.response.status, - error.response.data + response.status, + response.data ); return Promise.reject(error as Error); } - if (error.request) { + if (error?.request) { const errorData = error.toJSON(); logger.error("Request error:", errorData.code, errorData.message); return Promise.reject( @@ -197,7 +216,7 @@ export class HydraApi { ); } - logger.error("Error", error.message); + logger.error("Error", error?.message); return Promise.reject(error as Error); } ); diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 03bf6ad76..801b181e9 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -2,33 +2,35 @@ import { logsPath } from "@main/constants"; import log from "electron-log"; import path from "path"; -log.transports.file.resolvePathFn = ( - _: log.PathVariables, +const resolveLogPath = ( + _variables: unknown, message?: log.LogMessage | undefined ) => { if (message?.scope === "python-rpc") { - return path.join(logsPath, "pythonrpc.txt"); + return path.join(logsPath(), "pythonrpc.txt"); } if (message?.scope === "network") { - return path.join(logsPath, "network.txt"); + return path.join(logsPath(), "network.txt"); } if (message?.scope == "achievements") { - return path.join(logsPath, "achievements.txt"); + return path.join(logsPath(), "achievements.txt"); } if (message?.level === "error") { - return path.join(logsPath, "error.txt"); + return path.join(logsPath(), "error.txt"); } if (message?.level === "info") { - return path.join(logsPath, "info.txt"); + return path.join(logsPath(), "info.txt"); } - return path.join(logsPath, "logs.txt"); + return path.join(logsPath(), "logs.txt"); }; +log.transports.file.resolvePathFn = resolveLogPath; + log.errorHandler.startCatching({ showDialog: false, }); diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index a925e7c7b..3fcca9066 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -15,13 +15,6 @@ import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; import { getThemeSoundPath } from "@main/helpers"; -import { processProfileImage } from "@main/events/profile/process-profile-image"; - -const getStaticImage = async (path: string) => { - return processProfileImage(path, "jpg") - .then((response) => response.imagePath) - .catch(() => path); -}; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -38,9 +31,8 @@ async function downloadImage(url: string | null) { response.data.pipe(writer); return new Promise((resolve) => { - writer.on("finish", async () => { - const staticImagePath = await getStaticImage(outputPath); - resolve(staticImagePath); + writer.on("finish", () => { + resolve(outputPath); }); writer.on("error", () => { logger.error("Failed to download image", { url }); @@ -67,7 +59,7 @@ async function getAchievementSoundPath(): Promise { logger.error("Failed to get theme sound path", error); } - return achievementSoundPath; + return achievementSoundPath(); } export const publishDownloadCompleteNotification = async (game: Game) => { diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index db5bbee17..6055b7de1 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -164,11 +164,26 @@ export const watchProcesses = async () => { continue; } - const executable = executablePath - .slice(executablePath.lastIndexOf(platform === "win32" ? "\\" : "/") + 1) - .toLowerCase(); + const normalizedExecutablePath = path.normalize(executablePath); + const executable = path.basename(normalizedExecutablePath).toLowerCase(); - const hasProcess = processMap.get(executable)?.has(executablePath); + const paths = processMap.get(executable); + let hasProcess = false; + + if (paths) { + const normalizedTarget = normalizedExecutablePath.toLowerCase(); + + for (const processPath of paths) { + if (!processPath) continue; + + const normalizedProcessPath = path.normalize(processPath).toLowerCase(); + + if (normalizedProcessPath === normalizedTarget) { + hasProcess = true; + break; + } + } + } if (hasProcess) { if (gamesPlaytime.has(levelKeys.game(game.shop, game.objectId))) { diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 2a1dce792..0022d3ef1 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -106,6 +106,11 @@ export class PythonRPC { "main.py" ); + // In dev mode we always run the raw Python script instead of the + // bundled hydra-python-rpc.exe, because the local exe may be built + // with libtorrent and fail to start on systems where libtorrent + // cannot be loaded. The Python sources themselves handle missing + // libtorrent gracefully and simply disable torrent features. const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { stdio: ["inherit", "inherit"], }); diff --git a/src/main/services/system-path.ts b/src/main/services/system-path.ts index 32b34e116..13e25bc3f 100644 --- a/src/main/services/system-path.ts +++ b/src/main/services/system-path.ts @@ -1,5 +1,5 @@ import { app, dialog } from "electron"; -import { logger } from "./logger"; +import log from "electron-log"; export class SystemPath { static readonly paths = { @@ -21,9 +21,9 @@ export class SystemPath { try { app.getPath(pathName); } catch (error) { - logger.error(`Error getting path ${pathName}`); + log.error(`Error getting path ${pathName}`); if (error instanceof Error) { - logger.error(error.message, error.stack); + log.error(error.message, error.stack); } dialog.showErrorBox( @@ -38,7 +38,7 @@ export class SystemPath { try { return app.getPath(pathName); } catch (error) { - console.error(`Error getting path: ${error}`); + log.error(`Error getting path: ${error}`); return ""; } } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b5..09169dbef 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -197,7 +197,7 @@ export class WindowManager { this.mainWindow.removeMenu(); this.mainWindow.on("ready-to-show", () => { - if (!app.isPackaged || isStaging) + if (!app.isPackaged || isStaging()) WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); }); @@ -395,7 +395,7 @@ export class WindowManager { this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); this.loadWindowURL(this.notificationWindow, "achievement-notification"); - if (!app.isPackaged || isStaging) { + if (!app.isPackaged || isStaging()) { this.notificationWindow.webContents.openDevTools(); } } @@ -474,7 +474,7 @@ export class WindowManager { editorWindow.once("ready-to-show", () => { editorWindow.show(); this.mainWindow?.webContents.openDevTools(); - if (!app.isPackaged || isStaging) { + if (!app.isPackaged || isStaging()) { editorWindow.webContents.openDevTools(); } }); diff --git a/src/main/services/ws/events/friend-request.ts b/src/main/services/ws/events/friend-request.ts index efee370d2..8faa38a57 100644 --- a/src/main/services/ws/events/friend-request.ts +++ b/src/main/services/ws/events/friend-request.ts @@ -8,11 +8,9 @@ export const friendRequestEvent = async (payload: FriendRequest) => { friendRequestCount: payload.friendRequestCount, }); - if (payload.senderId) { - const user = await HydraApi.get(`/users/${payload.senderId}`); + const user = await HydraApi.get(`/users/${payload.senderId}`); - if (user) { - publishNewFriendRequestNotification(user); - } + if (user) { + publishNewFriendRequestNotification(user); } }; diff --git a/src/main/types.ts b/src/main/types.ts new file mode 100644 index 000000000..ff5e7bb59 --- /dev/null +++ b/src/main/types.ts @@ -0,0 +1 @@ +export type Lazy = () => T; diff --git a/src/preload/index.ts b/src/preload/index.ts index a29655321..a1ece34ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,8 @@ import type { ShortcutLocation, AchievementCustomNotificationPosition, AchievementNotificationInfo, + DownloadSourcesExportResult, + DownloadSourcesImportResult, } from "@types"; import type { AuthPage } from "@shared"; import type { AxiosProgressEvent } from "axios"; @@ -103,6 +105,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + exportDownloadSources: (filePath: string): Promise => + ipcRenderer.invoke("exportDownloadSources", filePath), + importDownloadSources: (filePath: string): Promise => + ipcRenderer.invoke("importDownloadSources", filePath), getDownloadSourcesCheckBaseline: () => ipcRenderer.invoke("getDownloadSourcesCheckBaseline"), getDownloadSourcesSinceValue: () => @@ -187,6 +193,12 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId), toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), + updateGameCollections: (params: { + shop: GameShop; + objectId: string; + tags?: string[]; + playStatus?: string | null; + }) => ipcRenderer.invoke("updateGameCollections", params), updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -345,6 +357,8 @@ contextBridge.exposeInMainWorld("electron", { openCheckout: () => ipcRenderer.invoke("openCheckout"), showOpenDialog: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke("showOpenDialog", options), + showSaveDialog: (options: Electron.SaveDialogOptions) => + ipcRenderer.invoke("showSaveDialog", options), showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), hydraApi: { diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 2c32c5da4..9f96ed653 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -11,7 +11,6 @@ import { import "./bottom-panel.scss"; import { useNavigate } from "react-router-dom"; -import { VERSION_CODENAME } from "@renderer/constants"; export function BottomPanel() { const { t } = useTranslation("bottom_panel"); @@ -26,16 +25,11 @@ export function BottomPanel() { const { lastPacket, progress, downloadSpeed, eta } = useDownload(); - const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); const [commonRedistStatus, setCommonRedistStatus] = useState( null ); - useEffect(() => { - window.electron.getVersion().then((result) => setVersion(result)); - }, []); - useEffect(() => { const unlisten = window.electron.onCommonRedistProgress( ({ log, complete }) => { @@ -126,8 +120,7 @@ export function BottomPanel() { className="bottom-panel__version-button" > - {sessionHash ? `${sessionHash} -` : ""} v{version} " - {VERSION_CODENAME}" + {sessionHash ? `${sessionHash} - ` : ""}3.7.4.v2.modified diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index 6fc3dccb1..91f56f08f 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -3,8 +3,21 @@ import { useEffect, useState } from "react"; import type { TrendingGame } from "@types"; import { useTranslation } from "react-i18next"; import Skeleton from "react-loading-skeleton"; +import { stripHtml } from "@shared"; import "./hero.scss"; +const decodeHtmlEntities = (value: string) => { + if (!value) return ""; + + try { + const textarea = document.createElement("textarea"); + textarea.innerHTML = value; + return textarea.value; + } catch { + return value; + } +}; + export function Hero() { const [featuredGameDetails, setFeaturedGameDetails] = useState< TrendingGame[] | null @@ -41,33 +54,38 @@ export function Hero() { } if (featuredGameDetails?.length) { - return featuredGameDetails.map((game) => ( - - )); + + ); + }); } return null; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e35ed57bb..3f86595b2 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -20,6 +20,7 @@ import type { ComparedAchievements, LibraryGame, GameRunning, + GamePlayStatus, TorBoxUser, Theme, Auth, @@ -31,6 +32,8 @@ import type { Game, DiskUsage, DownloadSource, + DownloadSourcesExportResult, + DownloadSourcesImportResult, } from "@types"; import type { AxiosProgressEvent } from "axios"; @@ -151,6 +154,12 @@ declare global { objectId: string, pinned: boolean ) => Promise; + updateGameCollections: (params: { + shop: GameShop; + objectId: string; + tags?: string[]; + playStatus?: GamePlayStatus | null; + }) => Promise; updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -221,6 +230,12 @@ declare global { syncDownloadSources: () => Promise; getDownloadSourcesCheckBaseline: () => Promise; getDownloadSourcesSinceValue: () => Promise; + exportDownloadSources: ( + filePath: string + ) => Promise; + importDownloadSources: ( + filePath: string + ) => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; @@ -277,6 +292,9 @@ declare global { showOpenDialog: ( options: Electron.OpenDialogOptions ) => Promise; + showSaveDialog: ( + options: Electron.SaveDialogOptions + ) => Promise; showItemInFolder: (path: string) => Promise; hydraApi: { get: ( diff --git a/src/renderer/src/pages/catalogue/catalogue.scss b/src/renderer/src/pages/catalogue/catalogue.scss index 37aca9cbf..d9daf7e05 100644 --- a/src/renderer/src/pages/catalogue/catalogue.scss +++ b/src/renderer/src/pages/catalogue/catalogue.scss @@ -55,6 +55,48 @@ gap: 8px; } + &__games-list { + width: 100%; + display: grid; + gap: calc(globals.$spacing-unit * 2); + + &--compact { + grid-template-columns: repeat(1, minmax(0, 1fr)); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + &--grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + } + + &--large { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + &__pagination-container { display: flex; align-items: center; diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index b9eb3c249..a41fd97f2 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -5,7 +5,8 @@ import type { } from "@types"; import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ViewOptions, ViewMode } from "../library/view-options"; import "./catalogue.scss"; @@ -33,6 +34,11 @@ export default function Catalogue() { const abortControllerRef = useRef(null); const cataloguePageRef = useRef(null); + const [viewMode, setViewMode] = useState(() => { + const savedViewMode = localStorage.getItem("catalogue-view-mode"); + return (savedViewMode as ViewMode) || "compact"; + }); + const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue(); const { steamGenres, steamUserTags, filters, page } = useAppSelector( @@ -51,6 +57,11 @@ export default function Catalogue() { const { t, i18n } = useTranslation("catalogue"); + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + localStorage.setItem("catalogue-view-mode", mode); + }, []); + const debouncedSearch = useRef( debounce( async ( @@ -267,19 +278,28 @@ export default function Catalogue() { ))} + +
- {isLoading ? ( - - {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - - ))} - - ) : ( - results.map((game) => ) - )} +
+ {isLoading ? ( + + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + + ))} + + ) : ( + results.map((game) => ) + )} +
diff --git a/src/renderer/src/pages/catalogue/game-item.scss b/src/renderer/src/pages/catalogue/game-item.scss index ec20b4a5b..fd84d5d7a 100644 --- a/src/renderer/src/pages/catalogue/game-item.scss +++ b/src/renderer/src/pages/catalogue/game-item.scss @@ -80,3 +80,35 @@ flex-wrap: wrap; } } + +// Adjust layout when used inside catalogue view modes +.catalogue__games-list--compact, +.catalogue__games-list--grid { + .game-item { + flex-direction: column; + align-items: stretch; + gap: 0; + + &__cover { + width: 100%; + height: 180px; + border-right: none; + border-bottom: 1px solid globals.$border-color; + } + + &__cover-placeholder { + width: 100%; + height: 180px; + border-right: none; + } + + &__details { + width: 100%; + padding: calc(globals.$spacing-unit * 2); + } + + &__genres { + margin-bottom: 4px; + } + } +} diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9facea..85552e8d1 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -351,20 +351,56 @@ export function DownloadGroup({ {getGameInfo(game)}
- {getGameActions(game) !== null && ( - - - - )} + {(() => { + const actions = getGameActions(game); + const primaryLabels = new Set([ + t("pause"), + t("resume"), + t("cancel"), + ]); + + const primaryActions = actions.filter((item) => + primaryLabels.has(item.label) + ); + const menuActions = actions.filter( + (item) => !primaryLabels.has(item.label) + ); + + if (!primaryActions.length && !menuActions.length) { + return null; + } + + return ( +
+ {primaryActions.map((item) => ( + + ))} + + {menuActions.length > 0 && ( + + + + )} +
+ ); + })()}
{game.download?.downloader === Downloader.Hydra && ( diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.scss b/src/renderer/src/pages/game-details/modals/game-options-modal.scss index f406f17b1..64e73bbdc 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.scss +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.scss @@ -83,6 +83,12 @@ gap: globals.$spacing-unit; } + &__tags { + display: flex; + flex-wrap: wrap; + gap: globals.$spacing-unit; + } + &__danger-zone { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index e658fbb8f..8d30dec3e 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,10 +1,10 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, CheckboxField, Modal, TextField } from "@renderer/components"; -import type { LibraryGame, ShortcutLocation } from "@types"; +import { Button, CheckboxField, Modal, TextField, SelectField, Badge } from "@renderer/components"; +import type { GamePlayStatus, LibraryGame, ShortcutLocation } from "@types"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; -import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; +import { useDownload, useToast, useUserDetails, useLibrary } from "@renderer/hooks"; import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; import { ResetAchievementsModal } from "./reset-achievements-modal"; import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal"; @@ -39,6 +39,8 @@ export function GameOptionsModal({ achievements, } = useContext(gameDetailsContext); + const { updateLibrary } = useLibrary(); + const { hasActiveSubscription } = useUserDetails(); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -52,6 +54,12 @@ export function GameOptionsModal({ game.automaticCloudSync ?? false ); const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false); + const [tagsInput, setTagsInput] = useState( + game.tags && game.tags.length ? game.tags.join(", ") : "" + ); + const [playStatus, setPlayStatus] = useState( + game.playStatus ?? "" + ); const { removeGameInstaller, @@ -115,7 +123,10 @@ export function GameOptionsModal({ window.electron .updateExecutablePath(game.shop, game.objectId, path) - .then(updateGame); + .then(async () => { + await updateGame(); + await updateLibrary(); + }); } }; @@ -169,7 +180,7 @@ export function GameOptionsModal({ const handleClearExecutablePath = async () => { await window.electron.updateExecutablePath(game.shop, game.objectId, null); - updateGame(); + await Promise.all([updateGame(), updateLibrary()]); }; const handleChangeWinePrefixPath = async () => { @@ -218,6 +229,56 @@ export function GameOptionsModal({ .then(updateGame); }; + const parseTags = (value: string): string[] => { + return Array.from( + new Set( + value + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + ) + ).slice(0, 20); + }; + + const handleTagsChange = (event: React.ChangeEvent) => { + setTagsInput(event.target.value); + }; + + const handlePersistCollections = async ( + params?: { tags?: string[]; playStatus?: GamePlayStatus | null } + ) => { + try { + await window.electron.updateGameCollections({ + shop: game.shop, + objectId: game.objectId, + tags: params?.tags, + playStatus: + params?.playStatus !== undefined + ? params.playStatus + : (playStatus || null), + }); + updateGame(); + } catch (error) { + logger.error("Failed to update game collections", error); + showErrorToast(t("update_playtime_error")); + } + }; + + const handleTagsBlur = async () => { + const tags = parseTags(tagsInput); + await handlePersistCollections({ tags }); + }; + + const handlePlayStatusChange = async ( + event: React.ChangeEvent + ) => { + const value = event.target.value as GamePlayStatus | ""; + setPlayStatus(value); + await handlePersistCollections({ playStatus: value || null }); + }; + + const tagsPreview = parseTags(tagsInput); + const shouldShowWinePrefixConfiguration = window.electron.platform === "linux"; @@ -452,6 +513,56 @@ export function GameOptionsModal({ />
+
+
+

{t("collections_section_title")}

+

+ {t("collections_section_description")} +

+
+ + + + {tagsPreview.length > 0 && ( +
+ {tagsPreview.map((tag) => ( + {tag} + ))} +
+ )} + + +
+ {game.shop !== "custom" && (
diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 420029c77..30f3da0a5 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -8,7 +8,13 @@ &__filter-top { margin-bottom: calc(globals.$spacing-unit * 2); display: flex; - flex-direction: row; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + } + + &__filter-actions { + display: flex; + flex-wrap: wrap; gap: calc(globals.$spacing-unit * 1); align-items: center; } diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 91013da06..07220a11c 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -49,6 +49,12 @@ export function RepacksModal({ [] ); const [filterTerm, setFilterTerm] = useState(""); + const [sizeSortDirection, setSizeSortDirection] = useState< + "none" | "asc" | "desc" + >("none"); + const [dateSortDirection, setDateSortDirection] = useState< + "none" | "asc" | "desc" + >("none"); const [hashesInDebrid, setHashesInDebrid] = useState>( {} @@ -80,6 +86,51 @@ export function RepacksModal({ return match ? match[1].toLowerCase() : null; }; + const parseFileSizeToGb = (fileSize: string | null): number | null => { + if (!fileSize) return null; + + const lower = fileSize.toLowerCase(); + const match = lower.match(/([0-9]+[0-9.,]*)/); + if (!match) return null; + + const normalized = match[1].replace(",", "."); + const value = Number.parseFloat(normalized); + if (Number.isNaN(value)) return null; + + if (lower.includes("tb")) return value * 1024; + if (lower.includes("mb")) return value / 1024; + if (lower.includes("kb")) return value / (1024 * 1024); + + return value; + }; + + const parseUploadDateToTimestamp = ( + uploadDate: string | null + ): number | null => { + if (!uploadDate) return null; + + const direct = new Date(uploadDate); + if (!Number.isNaN(direct.getTime())) { + return direct.getTime(); + } + + const match = uploadDate.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (match) { + const [, day, month, year] = match; + const parsed = new Date( + Number.parseInt(year, 10), + Number.parseInt(month, 10) - 1, + Number.parseInt(day, 10) + ); + const time = parsed.getTime(); + if (!Number.isNaN(time)) { + return time; + } + } + + return null; + }; + const { isFeatureEnabled, Feature } = useFeature(); useEffect(() => { @@ -169,8 +220,41 @@ export function RepacksModal({ ); }); - setFilteredRepacks(bySource); - }, [sortedRepacks, filterTerm, selectedFingerprints, downloadSources]); + let sortedBy = bySource; + + if (sizeSortDirection !== "none") { + sortedBy = [...sortedBy].sort((a, b) => { + const sizeA = parseFileSizeToGb(a.fileSize); + const sizeB = parseFileSizeToGb(b.fileSize); + + if (sizeA === null && sizeB === null) return 0; + if (sizeA === null) return 1; + if (sizeB === null) return -1; + + return sizeSortDirection === "asc" ? sizeA - sizeB : sizeB - sizeA; + }); + } else if (dateSortDirection !== "none") { + sortedBy = [...sortedBy].sort((a, b) => { + const dateA = parseUploadDateToTimestamp(a.uploadDate); + const dateB = parseUploadDateToTimestamp(b.uploadDate); + + if (dateA === null && dateB === null) return 0; + if (dateA === null) return 1; + if (dateB === null) return -1; + + return dateSortDirection === "asc" ? dateA - dateB : dateB - dateA; + }); + } + + setFilteredRepacks(sortedBy); + }, [ + sortedRepacks, + filterTerm, + selectedFingerprints, + downloadSources, + sizeSortDirection, + dateSortDirection, + ]); const handleRepackClick = (repack: GameRepack) => { setRepack(repack); @@ -219,6 +303,28 @@ export function RepacksModal({ } }, [visible]); + const toggleSizeSortDirection = () => { + setSizeSortDirection((prev) => { + if (prev === "none") { + setDateSortDirection("none"); + return "desc"; + } + if (prev === "desc") return "asc"; + return "none"; + }); + }; + + const toggleDateSortDirection = () => { + setDateSortDirection((prev) => { + if (prev === "none") { + setSizeSortDirection("none"); + return "desc"; + } + if (prev === "desc") return "asc"; + return "none"; + }); + }; + return ( <> - {downloadSources.length > 0 && ( +
+ {downloadSources.length > 0 && ( + + )} - )} + +
{ - if (game.unlockedAchievementCount) return; - - window.electron - .getUnlockedAchievements(game.objectId, game.shop) - .then((achievements) => { - setUnlockedAchievementsCount( - achievements.filter((a) => a.unlocked).length - ); - }); - }, [game]); - const backgroundStyle = useMemo( () => backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, @@ -72,12 +57,15 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ const achievementBarStyle = useMemo( () => ({ - width: `${(unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100}%`, + width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`, }), - [unlockedAchievementsCount, game.achievementCount] + [game.unlockedAchievementCount, game.achievementCount] ); const logoImage = game.customLogoImageUrl ?? game.logoImageUrl; + const tags = game.tags ?? []; + const visibleTags = tags.slice(0, 3); + const hasAchievements = (game.achievementCount ?? 0) > 0; return (
diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index ab9a9f2ac..a5b04eb1a 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -66,6 +66,15 @@ width: 100%; } + &__bottom-section { + display: flex; + width: 100%; + justify-content: space-between; + align-items: flex-end; + gap: globals.$spacing-unit; + margin-top: auto; + } + &__playtime { background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(8px); @@ -171,6 +180,14 @@ white-space: nowrap; } + &__tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; + max-width: 50%; + } + &__action-button { position: absolute; bottom: 8px; diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index a91176cbe..9ebee19dc 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -2,6 +2,7 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; import { memo } from "react"; import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { Badge } from "@renderer/components"; import "./library-game-card.scss"; interface LibraryGameCardProps { @@ -25,6 +26,10 @@ export const LibraryGameCard = memo(function LibraryGameCard({ const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); + const tags = game.tags ?? []; + const visibleTags = tags.slice(0, 3); + const hasAchievements = (game.achievementCount ?? 0) > 0; + const coverImage = ( game.customIconUrl ?? game.coverImageUrl ?? @@ -64,36 +69,48 @@ export const LibraryGameCard = memo(function LibraryGameCard({
- {(game.achievementCount ?? 0) > 0 && ( -
-
-
- - - {game.unlockedAchievementCount ?? 0} /{" "} - {game.achievementCount ?? 0} - + {(hasAchievements || visibleTags.length > 0) && ( +
+ {hasAchievements && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
- - {Math.round( - ((game.unlockedAchievementCount ?? 0) / - (game.achievementCount ?? 1)) * - 100 - )} - % - -
-
-
-
+ )} + + {visibleTags.length > 0 && ( +
+ {visibleTags.map((tag) => ( + {tag} + ))} +
+ )}
)}
diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss index ffc68b839..60ac19036 100644 --- a/src/renderer/src/pages/library/library.scss +++ b/src/renderer/src/pages/library/library.scss @@ -57,6 +57,23 @@ gap: calc(globals.$spacing-unit); } + &__status-filter { + min-width: 140px; + } + + &__tag-filter { + max-width: 220px; + + .text-field-container__text-field--dark { + background-color: globals.$dark-background-color; + border-color: rgba(255, 255, 255, 0.15); + } + + .text-field-container__text-field--dark:hover { + border-color: rgba(255, 255, 255, 0.5); + } + } + &__header-controls { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 0efe8fb22..581c27c0a 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -4,8 +4,8 @@ import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; -import { LibraryGame } from "@types"; -import { GameContextMenu } from "@renderer/components"; +import type { GamePlayStatus, LibraryGame } from "@types"; +import { GameContextMenu, SelectField, TextField } from "@renderer/components"; import { LibraryGameCard } from "./library-game-card"; import { LibraryGameCardLarge } from "./library-game-card-large"; import { ViewOptions, ViewMode } from "./view-options"; @@ -14,12 +14,20 @@ import "./library.scss"; export default function Library() { const { library, updateLibrary } = useLibrary(); + type ElectronAPI = { + refreshLibraryAssets?: () => Promise; + onLibraryBatchComplete?: (cb: () => void) => () => void; + }; const [viewMode, setViewMode] = useState(() => { const savedViewMode = localStorage.getItem("library-view-mode"); return (savedViewMode as ViewMode) || "compact"; }); const [filterBy, setFilterBy] = useState("all"); + type StatusFilter = "all" | "installed" | "not_installed" | GamePlayStatus; + + const [statusFilter, setStatusFilter] = useState("all"); + const [tagFilter, setTagFilter] = useState(""); const [contextMenu, setContextMenu] = useState<{ game: LibraryGame | null; visible: boolean; @@ -37,15 +45,22 @@ export default function Library() { useEffect(() => { dispatch(setHeaderTitle(t("library"))); - - const unsubscribe = window.electron.onLibraryBatchComplete(() => { + const electron = (globalThis as unknown as { electron?: ElectronAPI }) + .electron; + let unsubscribe: () => void = () => undefined; + if (electron?.refreshLibraryAssets) { + electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); + if (electron.onLibraryBatchComplete) { + unsubscribe = electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + } + } else { updateLibrary(); - }); - - window.electron - .refreshLibraryAssets() - .then(() => updateLibrary()) - .catch(() => updateLibrary()); + } return () => { unsubscribe(); @@ -72,7 +87,7 @@ export default function Library() { }, []); const filteredLibrary = useMemo(() => { - let filtered; + let filtered: LibraryGame[]; switch (filterBy) { case "recently_played": @@ -86,6 +101,23 @@ export default function Library() { filtered = library; } + if (statusFilter !== "all") { + if (statusFilter === "installed") { + filtered = filtered.filter((game) => !!game.executablePath); + } else if (statusFilter === "not_installed") { + filtered = filtered.filter((game) => !game.executablePath); + } else { + filtered = filtered.filter((game) => game.playStatus === statusFilter); + } + } + + if (tagFilter.trim()) { + const tagLower = tagFilter.trim().toLowerCase(); + filtered = filtered.filter((game) => + (game.tags ?? []).some((tag) => tag.toLowerCase() === tagLower) + ); + } + if (!searchQuery.trim()) return filtered; const queryLower = searchQuery.toLowerCase(); @@ -105,7 +137,7 @@ export default function Library() { return queryIndex === queryLower.length; }); - }, [library, filterBy, searchQuery]); + }, [library, filterBy, statusFilter, tagFilter, searchQuery]); const sortedLibrary = filteredLibrary; @@ -144,6 +176,55 @@ export default function Library() {
+ + setStatusFilter( + (event.target.value as StatusFilter) ?? "all" + ) + } + options={[ + { + key: "all", + value: "all", + label: t("play_status_all", { ns: "library" }), + }, + { + key: "installed", + value: "installed", + label: t("play_status_installed", { ns: "game_details" }), + }, + { + key: "not_installed", + value: "not_installed", + label: t("play_status_not_installed", { + ns: "game_details", + }), + }, + { + key: "completed", + value: "completed", + label: t("play_status_completed", { ns: "game_details" }), + }, + { + key: "abandoned", + value: "abandoned", + label: t("play_status_abandoned", { ns: "game_details" }), + }, + ]} + className="library__status-filter" + /> + + setTagFilter(event.target.value)} + containerProps={{ className: "library__tag-filter" }} + /> { + const timestamp = new Date().toISOString().replace(/[:]/g, "-"); + return `hydra-download-sources-${timestamp}.json`; + }; + + const handleExportDownloadSources = async () => { + const { canceled, filePath } = await window.electron.showSaveDialog({ + title: t("export_download_sources"), + defaultPath: getDefaultExportFileName(), + filters: [ + { + name: t("download_sources_json_filter"), + extensions: ["json"], + }, + ], + }); + + if (canceled || !filePath) { + return; + } + + setIsExportingDownloadSources(true); + + try { + const result = await window.electron.exportDownloadSources(filePath); + const countFormatted = result.exported.toLocaleString(); + showSuccessToast( + t("download_sources_export_success", { + count: result.exported, + countFormatted, + }) + ); + } catch (error) { + logger.error("Failed to export download sources:", error); + showErrorToast(t("download_sources_export_failed")); + } finally { + setIsExportingDownloadSources(false); + } + }; + + const handleImportDownloadSources = async () => { + const { canceled, filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("download_sources_json_filter"), + extensions: ["json"], + }, + ], + }); + + if (canceled || !filePaths || !filePaths.length) { + return; + } + + const [selectedFilePath] = filePaths; + + setIsImportingDownloadSources(true); + + try { + const result = await window.electron.importDownloadSources( + selectedFilePath + ); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + + if (result.imported > 0) { + const importedFormatted = result.imported.toLocaleString(); + showSuccessToast( + t("download_sources_import_success", { + count: result.imported, + countFormatted: importedFormatted, + }) + ); + } + + if (result.skipped > 0) { + const skippedFormatted = result.skipped.toLocaleString(); + showWarningToast( + t("download_sources_import_skipped", { + count: result.skipped, + countFormatted: skippedFormatted, + }) + ); + } + } catch (error) { + logger.error("Failed to import download sources:", error); + showErrorToast(t("download_sources_import_failed")); + } finally { + setIsImportingDownloadSources(false); + } + }; + return ( <> @@ -199,6 +300,45 @@ export function SettingsDownloadSources() {
+ + + +