Merge branch 'main' into linux-install

This commit is contained in:
Daniel Freitas 2024-05-24 13:54:41 -03:00 committed by GitHub
commit ee17b5106c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 2494 additions and 2399 deletions

View file

@ -55,62 +55,62 @@
"remove_from_list": "إزالة",
"space_left_on_disk": "{{space}} متبقية على القرص",
"eta": "الوقت المتبقي {{eta}}",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"checking_files": "جاري التحقق من الملفات...",
"filter": "تصفية حزم إعادة التجميع",
"requirements": "متطلبات النظام",
"minimum": "الحد الأدنى",
"recommended": "موصى به",
"no_minimum_requirements": "{{title}} لا تتوفر معلومات عن الحد الأدنى للمتطلبات",
"no_recommended_requirements": "{{title}} لا تتوفر معلومات عن المتطلبات الموصى بها",
"paused_progress": "{{progress}} (متوقف)",
"release_date": "تم الإصدار في {{date}}",
"publisher": "نشر بواسطة {{publisher}}",
"copy_link_to_clipboard": "نسخ الرابط",
"copied_link_to_clipboard": "تم نسخ الرابط",
"hours": "ساعات",
"minutes": "دقائق",
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"accuracy": "دقة {{accuracy}}%",
"add_to_library": "إضافة إلى المكتبة",
"remove_from_library": "إزالة من المكتبة",
"no_downloads": "لا توجد تنزيلات متاحة",
"play_time": "تم اللعب لمدة {{amount}}",
"last_time_played": "آخر مرة لعبت {{period}}",
"not_played_yet": "لم تلعب {{title}} بعد",
"next_suggestion": "الاقتراح التالي",
"play": "لعب",
"deleting": "جاري حذف المثبت...",
"close": "إغلاق",
"playing_now": "قيد التشغيل الآن",
"change": "تغيير",
"repacks_modal_description": "اختر الحزمة التي تريد تنزيلها",
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى الإعدادات",
"download_now": "تنزيل الآن",
"installation_instructions": "إرشادات التثبيت",
"installation_instructions_description": "هناك خطوات إضافية مطلوبة لتثبيت هذه اللعبة",
"online_fix_instruction": "تتطلب ألعاب OnlineFix كلمة مرور لاستخراجها. عند الحاجة، استخدم كلمة المرور التالية:",
"dodi_installation_instruction": "عند فتح مثبت DODI، اضغط على مفتاح التشغيل لأعلى <0 /> لبدء عملية التثبيت:",
"dont_show_it_again": "لا تعرضها مرة أخرى",
"copy_to_clipboard": "نسخ",
"copied_to_clipboard": "تم النسخ",
"got_it": "حسنأ",
"no_shop_details": "لم يتم استرداد تفاصيل المتجر.",
"download_options": "خيارات التنزيل",
"download_path": "مسار التنزيل",
"previous_screenshot": "لقطة الشاشة السابقة",
"next_screenshot": "لقطة الشاشة التالية",
"screenshot": "لقطة شاشة {{number}}",
"open_screenshot": "افتح لقطة الشاشة {{number}}"
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"checking_files": "جاري التحقق من الملفات...",
"filter": "تصفية حزم إعادة التجميع",
"requirements": "متطلبات النظام",
"minimum": "الحد الأدنى",
"recommended": "موصى به",
"no_minimum_requirements": "{{title}} لا تتوفر معلومات عن الحد الأدنى للمتطلبات",
"no_recommended_requirements": "{{title}} لا تتوفر معلومات عن المتطلبات الموصى بها",
"paused_progress": "{{progress}} (متوقف)",
"release_date": "تم الإصدار في {{date}}",
"publisher": "نشر بواسطة {{publisher}}",
"copy_link_to_clipboard": "نسخ الرابط",
"copied_link_to_clipboard": "تم نسخ الرابط",
"hours": "ساعات",
"minutes": "دقائق",
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"accuracy": "دقة {{accuracy}}%",
"add_to_library": "إضافة إلى المكتبة",
"remove_from_library": "إزالة من المكتبة",
"no_downloads": "لا توجد تنزيلات متاحة",
"play_time": "تم اللعب لمدة {{amount}}",
"last_time_played": "آخر مرة لعبت {{period}}",
"not_played_yet": "لم تلعب {{title}} بعد",
"next_suggestion": "الاقتراح التالي",
"play": "لعب",
"deleting": "جاري حذف المثبت...",
"close": "إغلاق",
"playing_now": "قيد التشغيل الآن",
"change": "تغيير",
"repacks_modal_description": "اختر الحزمة التي تريد تنزيلها",
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى الإعدادات",
"download_now": "تنزيل الآن",
"installation_instructions": "إرشادات التثبيت",
"installation_instructions_description": "هناك خطوات إضافية مطلوبة لتثبيت هذه اللعبة",
"online_fix_instruction": "تتطلب ألعاب OnlineFix كلمة مرور لاستخراجها. عند الحاجة، استخدم كلمة المرور التالية:",
"dodi_installation_instruction": "عند فتح مثبت DODI، اضغط على مفتاح التشغيل لأعلى <0 /> لبدء عملية التثبيت:",
"dont_show_it_again": "لا تعرضها مرة أخرى",
"copy_to_clipboard": "نسخ",
"copied_to_clipboard": "تم النسخ",
"got_it": "حسنأ",
"no_shop_details": "لم يتم استرداد تفاصيل المتجر.",
"download_options": "خيارات التنزيل",
"download_path": "مسار التنزيل",
"previous_screenshot": "لقطة الشاشة السابقة",
"next_screenshot": "لقطة الشاشة التالية",
"screenshot": "لقطة شاشة {{number}}",
"open_screenshot": "افتح لقطة الشاشة {{number}}"
},
"activation": {
"title": "تفعيل هايدرا",
"installation_id": "معرف التثبيت:",
"enter_activation_code": "أدخل رمز التفعيل الخاص بك",
"message": "إذا كنت لا تعرف أين تسأل عن هذا ، فلا يجب أن يكون لديك هذا.",
"title": "تفعيل هايدرا",
"installation_id": "معرف التثبيت:",
"enter_activation_code": "أدخل رمز التفعيل الخاص بك",
"message": "إذا كنت لا تعرف أين تسأل عن هذا ، فلا يجب أن يكون لديك هذا.",
"activate": "تفعيل",
"loading": "جار التحميل…"
"loading": "جار التحميل…"
},
"downloads": {
"resume": "استئناف",
@ -146,7 +146,7 @@
"telemetry": "القياس عن بعد",
"telemetry_description": "تفعيل إحصائيات الاستخدام مجهولة المصدر",
"real_debrid_api_token_label": "رمز واجهة برمجة التطبيقات (API) لـReal Debrid ",
"quit_app_instead_hiding": "إنهاء هايدرا بدلاً من التصغير الى شريط الحالة",
"quit_app_instead_hiding": "إنهاء هايدرا بدلاً من التصغير الى شريط الحالة",
"launch_with_system": "تشغيل هايدرا عند بدء تشغيل النظام",
"general": "عام",
"behavior": "السلوك",
@ -177,4 +177,4 @@
"modal": {
"close": "زر إغلاق"
}
}
}

View file

@ -176,5 +176,11 @@
},
"modal": {
"close": "Close button"
},
"splash": {
"downloading_version": "Downloading version {{version}}",
"searching_updates": "Searching for updates",
"update_found": "Update {{version}} found",
"restarting_and_applying": "Restarting and applying update"
}
}

View file

@ -24,12 +24,12 @@
"github": "Contribuye en GitHub"
},
"header": {
"search": "Buscar",
"search": "Buscar juegos",
"home": "Inicio",
"catalogue": "Catálogo",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
"home": "Inicio"
"settings": "Ajustes"
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",
@ -37,12 +37,17 @@
"checking_files": "Analizando archivos de {{title}} - ({{percentage}} completado)",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Siguiente página",
"previous_page": "Pagina anterior"
},
"game_details": {
"open_download_options": "Ver opciones de descargas",
"download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el {{updated_at}}",
"install": "Instalar",
"resume": "Continuar",
"pause": "Pausa",
"cancel": "Cancelar",
@ -52,7 +57,7 @@
"eta": "Finalizando en {{eta}}",
"downloading_metadata": "Descargando metadatos…",
"checking_files": "Analizando archivos…",
"filter": "Filtrar repacks",
"filter": "Buscar repacks",
"requirements": "Requisitos del Sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
@ -71,20 +76,17 @@
"add_to_library": "Agregar a la biblioteca",
"remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles",
"next_suggestion": "Siguiente sugerencia",
"play_time": "Jugado por {{amount}}",
"install": "Instalar",
"play": "Jugar",
"not_played_yet": "Aún no has jugado a {{title}}",
"close": "Cerrar",
"deleting": "Eliminando instalador…",
"playing_now": "Jugando ahora",
"last_time_played": "Jugado por última vez {{period}}",
"got_it": "Entendido",
"not_played_yet": "Aún no has jugado a {{title}}",
"next_suggestion": "Siguiente sugerencia",
"play": "Jugar",
"deleting": "Eliminando instalador…",
"close": "Cerrar",
"playing_now": "Jugando ahora",
"change": "Cambiar",
"repacks_modal_description": "Selecciona el repack que quieres descargar",
"downloads_path": "Ruta de descarga",
"select_folder_hint": "Para cambiar la carpeta predeterminada, accede a",
"select_folder_hint": "Para cambiar la carpeta predeterminada, ve a <0>Ajustes</0>",
"download_now": "Descargar ahora",
"installation_instructions": "Instrucciones de instalación",
"installation_instructions_description": "Se requieren de pasos adicionales para instalar este juego",
@ -92,7 +94,15 @@
"dodi_installation_instruction": "Cuando abras el instalador de DODI, presiona la tecla hacia arriba del teclado <0 /> para iniciar el proceso de instalación:",
"dont_show_it_again": "No mostrar de nuevo",
"copy_to_clipboard": "Copiar",
"copied_to_clipboard": "Copiado"
"copied_to_clipboard": "Copiado",
"got_it": "Entendido",
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga",
"download_path": "Ruta de descarga",
"previous_screenshot": "Anterior captura",
"next_screenshot": "Siguiente captura",
"screenshot": "Captura {{number}}",
"open_screenshot": "Abrir captura {{number}}"
},
"activation": {
"title": "Activar Hydra",
@ -118,12 +128,14 @@
"downloading_metadata": "Descargando metadatos…",
"checking_files": "Verificando archivos…",
"starting_download": "Iniciando descarga…",
"remove_from_list": "Eliminar",
"delete": "Eliminar instalador",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
"delete_modal_title": "¿Estás seguro?",
"deleting": "Eliminando instalador…",
"install": "Instalar"
"delete": "Eliminar instalador",
"remove_from_list": "Eliminar",
"delete_modal_title": "¿Estás seguro?",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
"install": "Instalar",
"real_debrid": "Real Debrid",
"torrent": "Torrent"
},
"settings": {
"downloads_path": "Ruta de descarga",
@ -132,7 +144,16 @@
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo",
"telemetry": "Telemetría",
"telemetry_description": "Habilitar recopilación de datos de manera anónima"
"telemetry_description": "Habilitar recopilación de datos de manera anónima",
"real_debrid_api_token_label": "Token API de Real Debrid",
"quit_app_instead_hiding": "Salir de Hydra en vez de minimizar en la bandeja del sistema",
"launch_with_system": "Iniciar Hydra al inicio del sistema",
"general": "General",
"behavior": "Otros",
"enable_real_debrid": "Activar Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>.",
"save_changes": "Guardar cambios"
},
"notifications": {
"download_complete": "Descarga completada",
@ -156,8 +177,10 @@
"modal": {
"close": "Botón de cierre"
},
"catalogue": {
"next_page": "Siguiente página",
"previous_page": "Pagina anterior"
"splash": {
"downloading_version": "Descargando versión {{version}}",
"searching_updates": "Buscando actualizaciones",
"update_found": "Actualización {{version}} encontrada",
"restarting_and_applying": "Reiniciando y aplicando actualización"
}
}

View file

@ -15,7 +15,7 @@
"checking_files": "{{title}} ({{percentage}} - 파일 검사 중…)",
"paused": "{{title}} (일시 정지됨)",
"downloading": "{{title}} ({{percentage}} - 다운로드 중…)",
"filter": "필터 라이브러리",
"filter": "라이브러리 정렬",
"follow_us": "공식 SNS",
"home": "홈",
"discord": "공식 디스코드",
@ -54,10 +54,10 @@
"remove": "제거",
"remove_from_list": "목록에서 제거",
"space_left_on_disk": "여유 저장 용량 {{space}} 남음",
"eta": "료까지 {{eta}}",
"eta": "료까지 {{eta}}",
"downloading_metadata": "메타데이터 다운로드 중…",
"checking_files": "파일 검사 중…",
"filter": "리팩들을 다음과 같이 걸러내기",
"filter": "리팩들을 다음과 같이 정렬하기",
"requirements": "시스템 사양",
"minimum": "최저 사양",
"recommended": "권장 사양",
@ -91,8 +91,8 @@
"download_now": "지금 다운로드",
"installation_instructions": "설치 방법",
"installation_instructions_description": "이 게임을 설치하기 위해서는 추가적인 단계가 필요합니다",
"online_fix_instruction": "OnlineFix 게임들은 추출 시 암호가 필요합니다. 비밀번호를 물을 때 다음을 암호로 사용하기:",
"dodi_installation_instruction": "DODI 인스톨러를 열었다면 키보드의 위 방향키를 눌러 설치를 시작하세요:",
"online_fix_instruction": "OnlineFix 게임들은 압축 해제 시 암호가 필요합니다. 비밀번호를 물을 때 다음을 암호로 사용하기:",
"dodi_installation_instruction": "DODI 인스톨러를 실행했다면 키보드의 위 방향키를 눌러 설치를 시작하세요:",
"dont_show_it_again": "다시 보지 않기",
"copy_to_clipboard": "복사하기",
"copied_to_clipboard": "복사됨",

View file

@ -17,7 +17,11 @@
"downloading": "{{title}} ({{percentage}} - Pobieranie…)",
"filter": "Filtruj biblioteke",
"follow_us": "Śledź nas",
"home": "Główna"
"home": "Główna",
"discord": "Dołącz nasz Discord",
"telegram": "Dołącz nasz Telegram",
"x": "Śledź na X",
"github": "Przyczyń się na GitHub"
},
"header": {
"search": "Szukaj",
@ -66,6 +70,8 @@
"copied_link_to_clipboard": "Skopiowano łącze",
"hours": "godzin",
"minutes": "minut",
"amount_hours": "{{amount}} godzin",
"amount_minutes": "{{amount}} minut",
"accuracy": "{{accuracy}}% dokładność",
"add_to_library": "Dodaj do biblioteki",
"remove_from_library": "Usuń z biblioteki",
@ -80,9 +86,23 @@
"playing_now": "Granie teraz",
"change": "Zmień",
"repacks_modal_description": "Wybierz repack, który chcesz pobrać",
"downloads_path": "Ścieżka pobierania",
"select_folder_hint": "Aby zmienić domyślny folder, przejdź do",
"download_now": "Pobierz teraz"
"download_now": "Pobierz teraz",
"installation_instructions": "Instrukcja instalacji",
"installation_instructions_description": "Do zainstalowania tej gry wymagane są dodatkowe kroki",
"online_fix_instruction": "Gry OnlineFix wymagają hasła do wyodrębnienia. W razie potrzeby użyj następującego hasła:",
"dodi_installation_instruction": "Po otwarciu instalatora DODI naciśnij klawisz <0 /> w górę, aby rozpocząć proces instalacji:",
"dont_show_it_again": "Nie pokazuj tego ponownie",
"copy_to_clipboard": "Skopiuj",
"copied_to_clipboard": "Skopiowano",
"got_it": "Rozumiem",
"no_shop_details": "Nie udało się pobrać danych sklepu.",
"download_options": "Opcje pobierania",
"download_path": "Ścieżka pobierania",
"previous_screenshot": "Poprzedni zrzut ekranu",
"next_screenshot": "Następny zrzut ekranu",
"screenshot": "Zrzut ekranu {{number}}",
"open_screenshot": "Otwórz zrzut ekranu {{number}}"
},
"activation": {
"title": "Aktywuj Hydra",
@ -113,7 +133,9 @@
"remove_from_list": "Usuń",
"delete_modal_title": "Czy na pewno?",
"delete_modal_description": "Spowoduje to usunięcie wszystkich plików instalacyjnych z komputera",
"install": "Instaluj"
"install": "Instaluj",
"real_debrid": "Real Debrid",
"torrent": "Torrent"
},
"settings": {
"downloads_path": "Ścieżka pobierania",
@ -122,7 +144,16 @@
"enable_download_notifications": "Gdy pobieranie zostanie zakończone",
"enable_repack_list_notifications": "Gdy dodawany jest nowy repack",
"telemetry": "Telemetria",
"telemetry_description": "Włącz anonimowe statystyki użycia"
"telemetry_description": "Włącz anonimowe statystyki użycia",
"real_debrid_api_token_label": "Real Debrid API token",
"quit_app_instead_hiding": "Zamknij Hydr zamiast minimalizować do zasobnika",
"launch_with_system": "Uruchom Hydra przy starcie systemu",
"general": "Ogólne",
"behavior": "Zachowania",
"enable_real_debrid": "Włącz Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "Możesz uzyskać swój klucz API <0>tutaj</0>.",
"save_changes": "Zapisz zmiany"
},
"notifications": {
"download_complete": "Pobieranie zakończone",
@ -142,5 +173,8 @@
"title": "Programy nie są zainstalowane",
"description": "Pliki wykonywalne Wine lub Lutris nie zostały znalezione na twoim systemie",
"instructions": "Sprawdź prawidłowy sposób instalacji dowolnego z nich w swojej dystrybucji Linuksa, aby gra działała normalnie"
},
"modal": {
"close": "Zamknij"
}
}

View file

@ -176,5 +176,11 @@
},
"modal": {
"close": "Botão de fechar"
},
"splash": {
"downloading_version": "Baixando versão {{version}}",
"searching_updates": "Buscando atualizações",
"update_found": "Versão {{version}} encontrada",
"restarting_and_applying": "Reiniciando e aplicando atualização"
}
}

View file

@ -152,7 +152,7 @@
"behavior": "Поведение",
"enable_real_debrid": "Включить Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "API ключ можно получить <0>здесь/0>.",
"real_debrid_api_token_hint": "API ключ можно получить <0>здесь</0>.",
"save_changes": "Сохранить изменения"
},
"notifications": {

View file

@ -150,7 +150,7 @@
"launch_with_system": "随系统启动时运行应用程序",
"enable_real_debrid": "启用 Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "您可以将API密钥填入<0>这里</0>.",
"real_debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.",
"save_changes": "保存更改"
},
"notifications": {

View file

@ -12,34 +12,20 @@ export const repackersOn1337x = [
export const repackers = [
...repackersOn1337x,
"Xatab",
"CPG",
"TinyRepacks",
"CPG",
"GOG",
"onlinefix",
] as const;
export const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export const defaultDownloadsPath = app.getPath("downloads");
export const databasePath = path.join(
app.getPath("appData"),
app.getName(),
"hydra",
"hydra.db"
);
export const INSTALLATION_ID_LENGTH = 6;
export const ACTIVATION_KEY_MULTIPLIER = 7;
export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds")
: path.join(__dirname, "..", "..", "seeds");

View file

@ -1,33 +1,21 @@
import { DataSource } from "typeorm";
import {
Game,
GameShopCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
SteamGame,
} from "@main/entity";
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { databasePath } from "./constants";
import migrations from "./migrations";
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
export const createDataSource = (
options: Partial<BetterSqlite3ConnectionOptions>
) =>
new DataSource({
type: "better-sqlite3",
entities: [Game, Repack, UserPreferences, GameShopCache],
synchronize: true,
database: databasePath,
entities: [
Game,
Repack,
RepackerFriendlyName,
UserPreferences,
GameShopCache,
MigrationScript,
SteamGame,
],
...options,
});
export const dataSource = createDataSource({
synchronize: true,
migrations,
});

View file

@ -23,8 +23,8 @@ export class Game {
@Column("text")
title: string;
@Column("text")
iconUrl: string;
@Column("text", { nullable: true })
iconUrl: string | null;
@Column("text", { nullable: true })
folderName: string | null;

View file

@ -1,7 +1,4 @@
export * from "./game.entity";
export * from "./repack.entity";
export * from "./repacker-friendly-name.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./migration-script.entity";
export * from "./steam-game.entity";

View file

@ -1,22 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("migration_script")
export class MigrationScript {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
version: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -1,25 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("repacker_friendly_name")
export class RepackerFriendlyName {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
name: string;
@Column("text")
friendlyName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity("steam_game")
export class SteamGame {
@PrimaryColumn()
id: number;
@Column()
name: string;
}

View file

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

View file

@ -0,0 +1,48 @@
import { AppUpdaterEvents } from "@types";
import { registerEvent } from "../register-event";
import updater, { ProgressInfo, UpdateInfo } from "electron-updater";
import { WindowManager } from "@main/services";
import { app } from "electron";
const { autoUpdater } = updater;
const sendEvent = (event: AppUpdaterEvents) => {
WindowManager.splashWindow?.webContents.send("autoUpdaterEvent", event);
};
const mockValuesForDebug = async () => {
sendEvent({ type: "update-downloaded" });
};
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater
.addListener("error", () => {
sendEvent({ type: "error" });
})
.addListener("checking-for-update", () => {
sendEvent({ type: "checking-for-updates" });
})
.addListener("update-not-available", () => {
sendEvent({ type: "update-not-available" });
})
.addListener("update-available", (info: UpdateInfo) => {
sendEvent({ type: "update-available", info });
})
.addListener("update-downloaded", () => {
sendEvent({ type: "update-downloaded" });
})
.addListener("download-progress", (info: ProgressInfo) => {
sendEvent({ type: "download-progress", info });
})
.addListener("update-cancelled", () => {
sendEvent({ type: "update-cancelled" });
});
if (app.isPackaged) {
autoUpdater.checkForUpdates();
} else {
await mockValuesForDebug();
}
};
registerEvent("checkForUpdates", checkForUpdates);

View file

@ -0,0 +1,12 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import updater from "electron-updater";
const { autoUpdater } = updater;
const continueToMainWindow = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater.removeAllListeners();
WindowManager.prepareMainWindowAndCloseSplash();
};
registerEvent("continueToMainWindow", continueToMainWindow);

View file

@ -0,0 +1,17 @@
import { app } from "electron";
import { registerEvent } from "../register-event";
import updater from "electron-updater";
import { WindowManager } from "@main/services";
const { autoUpdater } = updater;
const restartAndInstallUpdate = async (_event: Electron.IpcMainInvokeEvent) => {
if (app.isPackaged) {
autoUpdater.quitAndInstall(true, true);
} else {
autoUpdater.removeAllListeners();
WindowManager.prepareMainWindowAndCloseSplash();
}
};
registerEvent("restartAndInstallUpdate", restartAndInstallUpdate);

View file

@ -92,7 +92,4 @@ const getRecentlyAddedCatalogue = async (
return results.slice(0, resultSize);
};
registerEvent(getCatalogue, {
name: "getCatalogue",
memoize: true,
});
registerEvent("getCatalogue", getCatalogue);

View file

@ -1,9 +1,10 @@
import { gameShopCacheRepository, steamGameRepository } from "@main/repository";
import { gameShopCacheRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getLocalizedSteamAppDetails = (
objectID: string,
@ -13,10 +14,11 @@ const getLocalizedSteamAppDetails = (
return getSteamAppDetails(objectID, language);
}
return Promise.all([
steamGameRepository.findOne({ where: { id: Number(objectID) } }),
getSteamAppDetails(objectID, language),
]).then(([steamGame, localizedAppDetails]) => {
return getSteamAppDetails(objectID, language).then((localizedAppDetails) => {
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
@ -72,7 +74,4 @@ const getGameShopDetails = async (
throw new Error("Not implemented");
};
registerEvent(getGameShopDetails, {
name: "getGameShopDetails",
memoize: true,
});
registerEvent("getGameShopDetails", getGameShopDetails);

View file

@ -36,7 +36,4 @@ const getGames = async (
return { results, cursor: i };
};
registerEvent(getGames, {
name: "getGames",
memoize: true,
});
registerEvent("getGames", getGames);

View file

@ -42,7 +42,4 @@ const getHowLongToBeat = async (
});
};
registerEvent(getHowLongToBeat, {
name: "getHowLongToBeat",
memoize: true,
});
registerEvent("getHowLongToBeat", getHowLongToBeat);

View file

@ -36,6 +36,4 @@ const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return state.games[state.index];
};
registerEvent(getRandomGame, {
name: "getRandomGame",
});
registerEvent("getRandomGame", getRandomGame);

View file

@ -8,7 +8,4 @@ const searchGameRepacks = (
return searchRepacks(query);
};
registerEvent(searchGameRepacks, {
name: "searchGameRepacks",
memoize: true,
});
registerEvent("searchGameRepacks", searchGameRepacks);

View file

@ -9,7 +9,4 @@ const searchGamesEvent = async (
return searchGames({ query, take: 12 });
};
registerEvent(searchGamesEvent, {
name: "searchGames",
memoize: true,
});
registerEvent("searchGames", searchGamesEvent);

View file

@ -7,6 +7,4 @@ const getDiskFreeSpace = async (
path: string
) => checkDiskSpace(path);
registerEvent(getDiskFreeSpace, {
name: "getDiskFreeSpace",
});
registerEvent("getDiskFreeSpace", getDiskFreeSpace);

View file

@ -14,7 +14,6 @@ import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/get-repackers-friendly-names";
import "./library/open-game";
import "./library/open-game-installer";
import "./library/remove-game";
@ -28,6 +27,9 @@ import "./torrenting/start-game-download";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";
import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./autoupdater/continue-to-main-window";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View file

@ -3,8 +3,8 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { getSteamGameIconUrl } from "@main/services";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -27,21 +27,31 @@ const addGameToLibrary = async (
)
.then(async ({ affected }) => {
if (!affected) {
const iconUrl = await getFileBase64(
await getSteamGameIconUrl(objectID)
);
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
await gameRepository.insert({
title,
iconUrl,
objectID,
shop: gameShop,
executablePath,
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
shop: gameShop,
executablePath,
})
.then(() => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
});
}
});
};
registerEvent(addGameToLibrary, {
name: "addGameToLibrary",
});
registerEvent("addGameToLibrary", addGameToLibrary);

View file

@ -36,6 +36,4 @@ const closeGame = async (
return false;
};
registerEvent(closeGame, {
name: "closeGame",
});
registerEvent("closeGame", closeGame);

View file

@ -47,6 +47,4 @@ const deleteGameFolder = async (
}
};
registerEvent(deleteGameFolder, {
name: "deleteGameFolder",
});
registerEvent("deleteGameFolder", deleteGameFolder);

View file

@ -16,6 +16,4 @@ const getGameByObjectID = async (
},
});
registerEvent(getGameByObjectID, {
name: "getGameByObjectID",
});
registerEvent("getGameByObjectID", getGameByObjectID);

View file

@ -28,6 +28,4 @@ const getLibrary = async () =>
)
);
registerEvent(getLibrary, {
name: "getLibrary",
});
registerEvent("getLibrary", getLibrary);

View file

@ -1,12 +0,0 @@
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getRepackersFriendlyNames = async () =>
stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
return { ...prev, [next.name]: next.friendlyName };
}, {});
registerEvent(getRepackersFriendlyNames, {
name: "getRepackersFriendlyNames",
memoize: true,
});

View file

@ -54,6 +54,4 @@ const openGameInstaller = async (
return false;
};
registerEvent(openGameInstaller, {
name: "openGameInstaller",
});
registerEvent("openGameInstaller", openGameInstaller);

View file

@ -13,6 +13,4 @@ const openGame = async (
shell.openPath(executablePath);
};
registerEvent(openGame, {
name: "openGame",
});
registerEvent("openGame", openGame);

View file

@ -8,6 +8,4 @@ const removeGameFromLibrary = async (
gameRepository.update({ id: gameId }, { isDeleted: true });
};
registerEvent(removeGameFromLibrary, {
name: "removeGameFromLibrary",
});
registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View file

@ -20,6 +20,4 @@ const removeGame = async (
);
};
registerEvent(removeGame, {
name: "removeGame",
});
registerEvent("removeGame", removeGame);

View file

@ -4,6 +4,4 @@ import { registerEvent } from "../register-event";
const openExternal = async (_event: Electron.IpcMainInvokeEvent, src: string) =>
shell.openExternal(src);
registerEvent(openExternal, {
name: "openExternal",
});
registerEvent("openExternal", openExternal);

View file

@ -13,6 +13,4 @@ const showOpenDialog = async (
throw new Error("Main window is not available");
};
registerEvent(showOpenDialog, {
name: "showOpenDialog",
});
registerEvent("showOpenDialog", showOpenDialog);

View file

@ -1,37 +1,11 @@
import { ipcMain } from "electron";
import { stateManager } from "@main/state-manager";
interface EventArgs {
name: string;
memoize?: boolean;
}
export const registerEvent = (
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
{ name, memoize = false }: EventArgs
name: string,
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any
) => {
ipcMain.handle(name, (event: Electron.IpcMainInvokeEvent, ...args) => {
const eventResults = stateManager.getValue("eventResults");
const keys = Array.from(eventResults.keys());
const key = [name, args] as [string, any[]];
const memoizationKey = keys.find(([memoizedEvent, memoizedArgs]) => {
const sameEvent = name === memoizedEvent;
const sameArgs = memoizedArgs.every((arg, index) => arg === args[index]);
return sameEvent && sameArgs;
});
if (memoizationKey) return eventResults.get(memoizationKey);
ipcMain.handle(name, async (event: Electron.IpcMainInvokeEvent, ...args) => {
return Promise.resolve(listener(event, ...args)).then((result) => {
if (memoize) {
eventResults.set(key, JSON.parse(JSON.stringify(result)));
stateManager.setValue("eventResults", eventResults);
}
if (!result) return result;
return JSON.parse(JSON.stringify(result));
});

View file

@ -50,6 +50,4 @@ const cancelGameDownload = async (
});
};
registerEvent(cancelGameDownload, {
name: "cancelGameDownload",
});
registerEvent("cancelGameDownload", cancelGameDownload);

View file

@ -27,6 +27,4 @@ const pauseGameDownload = async (
});
};
registerEvent(pauseGameDownload, {
name: "pauseGameDownload",
});
registerEvent("pauseGameDownload", pauseGameDownload);

View file

@ -46,6 +46,4 @@ const resumeGameDownload = async (
}
};
registerEvent(resumeGameDownload, {
name: "resumeGameDownload",
});
registerEvent("resumeGameDownload", resumeGameDownload);

View file

@ -1,4 +1,3 @@
import { getSteamGameIconUrl } from "@main/services";
import {
gameRepository,
repackRepository,
@ -8,10 +7,11 @@ import {
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { Downloader, GameStatus } from "@shared";
import { stateManager } from "@main/state-manager";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -76,18 +76,34 @@ const startGameDownload = async (
return game;
} else {
const iconUrl = await getFileBase64(await getSteamGameIconUrl(objectID));
const steamGame = stateManager
.getValue("steamGames")
.find((game) => game.id === Number(objectID));
const createdGame = await gameRepository.save({
title,
iconUrl,
objectID,
downloader,
shop: gameShop,
status: GameStatus.Downloading,
downloadPath,
repack: { id: repackId },
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
: null;
const createdGame = await gameRepository
.save({
title,
iconUrl,
objectID,
downloader,
shop: gameShop,
status: GameStatus.Downloading,
downloadPath,
repack: { id: repackId },
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
return result;
});
DownloadManager.downloadGame(createdGame.id);
@ -97,6 +113,4 @@ const startGameDownload = async (
}
};
registerEvent(startGameDownload, {
name: "startGameDownload",
});
registerEvent("startGameDownload", startGameDownload);

View file

@ -16,6 +16,4 @@ const autoLaunch = async (
}
};
registerEvent(autoLaunch, {
name: "autoLaunch",
});
registerEvent("autoLaunch", autoLaunch);

View file

@ -6,6 +6,4 @@ const getUserPreferences = async () =>
where: { id: 1 },
});
registerEvent(getUserPreferences, {
name: "getUserPreferences",
});
registerEvent("getUserPreferences", getUserPreferences);

View file

@ -21,6 +21,4 @@ const updateUserPreferences = async (
);
};
registerEvent(updateUserPreferences, {
name: "updateUserPreferences",
});
registerEvent("updateUserPreferences", updateUserPreferences);

View file

@ -13,7 +13,7 @@ import {
gogFormatter,
onlinefixFormatter,
} from "./formatters";
import { months, repackers } from "../constants";
import { repackers } from "../constants";
export const pipe =
<T>(...fns: ((arg: T) => any)[]) =>
@ -44,19 +44,6 @@ export const repackerFormatter: Record<
onlinefix: onlinefixFormatter,
};
export const formatUploadDate = (str: string) => {
const date = new Date();
const [month, day, year] = str.split(" ");
date.setMonth(months.indexOf(month.replace(".", "")));
date.setDate(Number(day.substring(0, 2)));
date.setFullYear(Number("20" + year.replace("'", "")));
date.setHours(0, 0, 0, 0);
return date;
};
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
objectID: string,

View file

@ -1,29 +1,26 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { resolveDatabaseUpdates, WindowManager } from "@main/services";
import { logger, resolveDatabaseUpdates, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
const { autoUpdater } = updater;
autoUpdater.setFeedURL({
provider: "github",
owner: "hydralauncher",
repo: "hydra",
});
autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.telemetryEnabled) return event;
return null;
},
});
}
app.commandLine.appendSwitch("--no-sandbox");
i18n.init({
resources,
@ -57,6 +54,8 @@ app.whenReady().then(() => {
);
dataSource.initialize().then(async () => {
await dataSource.runMigrations();
await resolveDatabaseUpdates();
await import("./main");
@ -65,7 +64,7 @@ app.whenReady().then(() => {
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSplashScreen();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
});

View file

@ -1,8 +1,7 @@
import { stateManager } from "./state-manager";
import { repackers } from "./constants";
import { repackersOn1337x, seedsPath } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
@ -12,8 +11,6 @@ import {
import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentDownloader } from "./services";
@ -22,12 +19,16 @@ import { Notification } from "electron";
import { t } from "i18next";
import { GameStatus } from "@shared";
import { In } from "typeorm";
import fs from "node:fs";
import path from "node:path";
import { RealDebridClient } from "./services/real-debrid";
import { orderBy } from "lodash-es";
import { SteamGame } from "@types";
startProcessWatcher();
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
for (const repacker of repackersOn1337x) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
@ -39,19 +40,16 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
track1337xUsers(existingRepacks),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewRepacksFromCPG(
existingRepacks.filter((repack) => repack.repacker === "CPG")
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromOnlineFix(
existingRepacks.filter((repack) => repack.repacker === "onlinefix")
),
track1337xUsers(existingRepacks),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
@ -74,23 +72,18 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
};
const loadState = async (userPreferences: UserPreferences | null) => {
const [friendlyNames, repacks, steamGames] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
steamGameRepository.find({
order: {
name: "asc",
},
}),
]);
const repacks = await repackRepository.find({
order: {
createdAt: "desc",
},
});
const steamGames = JSON.parse(
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
) as SteamGame[];
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
stateManager.setValue("steamGames", steamGames);
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
import("./events");

View file

@ -0,0 +1,78 @@
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { app } from "electron";
import { chunk } from "lodash-es";
import path from "path";
import { In, MigrationInterface, QueryRunner, Table } from "typeorm";
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "repack_temp",
columns: [
{ name: "title", type: "varchar" },
{ name: "old_id", type: "int" },
],
}),
true
);
await queryRunner.query(
`INSERT INTO repack_temp (title, old_id) SELECT title, id FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
await queryRunner.query(
`DELETE FROM repack WHERE repacker IN ('onlinefix', 'Xatab');`
);
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "hydra.db"),
});
await updateDataSource.initialize();
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updatedRepacks = await updateRepackRepository.find({
where: {
repacker: In(["onlinefix", "Xatab"]),
},
});
const chunks = chunk(
updatedRepacks.map((repack) => {
const { id: _, ...rest } = repack;
return rest;
}),
500
);
for (const chunk of chunks) {
await queryRunner.manager
.createQueryBuilder(Repack, "repack")
.insert()
.values(chunk)
.orIgnore()
.execute();
}
await queryRunner.query(
`UPDATE game
SET repackId = (
SELECT id
from repack LEFT JOIN repack_temp ON repack_temp.title = repack.title
WHERE repack_temp.old_id = game.repackId
)
WHERE EXISTS (select old_id from repack_temp WHERE old_id = game.repackId)`
);
await queryRunner.dropTable("repack_temp");
}
public async down(_: QueryRunner): Promise<void> {
return;
}
}

View file

@ -0,0 +1,3 @@
import { FixRepackUploadDate1715900413313 } from "./1715900413313-fix_repack_uploadDate";
export default [FixRepackUploadDate1715900413313];

View file

@ -1,27 +1,11 @@
import { dataSource } from "./data-source";
import {
Game,
GameShopCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
SteamGame,
} from "@main/entity";
import { Game, GameShopCache, Repack, UserPreferences } from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
export const repackRepository = dataSource.getRepository(Repack);
export const repackerFriendlyNameRepository =
dataSource.getRepository(RepackerFriendlyName);
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const migrationScriptRepository =
dataSource.getRepository(MigrationScript);
export const steamGameRepository = dataSource.getRepository(SteamGame);

View file

@ -0,0 +1,40 @@
import path from "node:path";
import fs from "node:fs";
import { getSteamGameClientIcon, logger } from "@main/services";
import { chunk } from "lodash-es";
import { seedsPath } from "@main/constants";
import type { SteamGame } from "@types";
const steamGamesPath = path.join(seedsPath, "steam-games.json");
const steamGames = JSON.parse(
fs.readFileSync(steamGamesPath, "utf-8")
) as SteamGame[];
const chunks = chunk(steamGames, 1500);
for (const chunk of chunks) {
await Promise.all(
chunk.map(async (steamGame) => {
if (steamGame.clientIcon) return;
const index = steamGames.findIndex((game) => game.id === steamGame.id);
try {
const clientIcon = await getSteamGameClientIcon(String(steamGame.id));
steamGames[index].clientIcon = clientIcon;
logger.log("info", `Set ${steamGame.name} client icon`);
} catch (err) {
steamGames[index].clientIcon = null;
logger.log("info", `Could not set icon for ${steamGame.name}`);
}
})
);
fs.writeFileSync(steamGamesPath, JSON.stringify(steamGames));
logger.log("info", "Updated steam games");
}

View file

@ -1,7 +1,6 @@
import path from "node:path";
import cp from "node:child_process";
import fs from "node:fs";
import * as Sentry from "@sentry/electron/main";
import { app, dialog } from "electron";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
@ -87,8 +86,6 @@ export class TorrentDownloader extends Downloader {
downloadSpeed: payload.downloadSpeed,
timeRemaining: payload.timeRemaining,
});
} catch (err) {
Sentry.captureException(err);
} finally {
await new Promise((resolve) => setTimeout(resolve, 100));
}

View file

@ -1,13 +1,39 @@
import { JSDOM } from "jsdom";
import { formatUploadDate } from "@main/helpers";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
const formatUploadDate = (str: string) => {
const date = new Date();
const [month, day, year] = str.split(" ");
date.setMonth(months.indexOf(month.replace(".", "")));
date.setDate(Number(day.substring(0, 2)));
date.setFullYear(Number("20" + year.replace("'", "")));
date.setHours(0, 0, 0, 0);
return date;
};
/* TODO: $a will often be null */
const getTorrentDetails = async (path: string) => {
const response = await request1337x(path);

View file

@ -1,64 +0,0 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import { logger } from "../logger";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
export const getNewRepacksFromCPG = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://cpgrepacks.site/page/${page}`);
const { window } = new JSDOM(data);
const repacks: QueryDeepPartialEntity<Repack>[] = [];
try {
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
const $title = $post.querySelector(".entry-title")!;
const uploadDate = $post.querySelector("time")?.getAttribute("datetime");
const $downloadInfo = Array.from(
$post.querySelectorAll(".wp-block-heading")
).find(($heading) => $heading.textContent?.startsWith("Download"));
/* Side note: CPG often misspells "Magnet" as "Magent" */
const $magnet = Array.from($post.querySelectorAll("a")).find(
($a) =>
$a.textContent?.startsWith("Magnet") ||
$a.textContent?.startsWith("Magent")
);
const fileSize = ($downloadInfo?.textContent ?? "")
.split("Download link => ")
.at(1);
repacks.push({
title: $title.textContent!,
fileSize: fileSize ?? "N/A",
magnet: $magnet!.href,
repacker: "CPG",
page,
uploadDate: uploadDate ? new Date(uploadDate) : new Date(),
});
});
} catch (err: unknown) {
logger.error((err as Error).message, { method: "getNewRepacksFromCPG" });
}
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromCPG(existingRepacks, page + 1);
};

View file

@ -6,32 +6,57 @@ import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity
const virtualConsole = new VirtualConsole();
const getUploadDate = (document: Document) => {
const $modifiedTime = document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
if ($modifiedTime) return $modifiedTime.content;
const $publishedTime = document.querySelector(
'[property="article:published_time"]'
) as HTMLMetaElement;
return $publishedTime.content;
};
const getDownloadLink = (document: Document) => {
const $latestDownloadButton = document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
if ($latestDownloadButton) return $latestDownloadButton.href;
const $downloadButton = document.querySelector(
".download-btn"
) as HTMLAnchorElement;
if (!$downloadButton) return null;
return $downloadButton.href;
};
const getMagnet = (downloadLink: string) => {
if (downloadLink.startsWith("http")) {
const { searchParams } = new URL(downloadLink);
return Buffer.from(searchParams.get("url")!, "base64").toString("utf-8");
}
return downloadLink;
};
const getGOGGame = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data, { virtualConsole });
const $modifiedTime = window.document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
const downloadLink = getDownloadLink(window.document);
if (!downloadLink) return null;
const $em = window.document.querySelector(
"p:not(.lightweight-accordion *) em"
)!;
const $em = window.document.querySelector("p em");
if (!$em) return null;
const fileSize = $em.textContent!.split("Size: ").at(1);
const $downloadButton = window.document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
const { searchParams } = new URL($downloadButton.href);
const magnet = Buffer.from(searchParams.get("url")!, "base64").toString(
"utf-8"
);
return {
fileSize: fileSize ?? "N/A",
uploadDate: new Date($modifiedTime.content),
uploadDate: new Date(getUploadDate(window.document)),
repacker: "GOG",
magnet,
magnet: getMagnet(downloadLink),
page: 1,
};
};
@ -62,7 +87,7 @@ export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
if (!gameExists) {
const game = await getGOGGame(href);
repacks.push({ ...game, title });
if (game) repacks.push({ ...game, title });
}
}

View file

@ -1,3 +1,4 @@
import axios from "axios";
import UserAgent from "user-agents";
import type { Repack } from "@main/entity";
@ -13,12 +14,13 @@ export const savePage = async (repacks: QueryDeepPartialEntity<Repack>[]) =>
export const requestWebPage = async (url: string) => {
const userAgent = new UserAgent();
return fetch(url, {
method: "GET",
headers: {
"User-Agent": userAgent.toString(),
},
}).then((response) => response.text());
return axios
.get(url, {
headers: {
"User-Agent": userAgent.toString(),
},
})
.then((response) => response.data);
};
export const decodeNonUtf8Response = async (res: Response) => {

View file

@ -1,5 +1,4 @@
export * from "./1337x";
export * from "./xatab";
export * from "./cpg-repacks";
export * from "./gog";
export * from "./online-fix";

View file

@ -1,22 +1,21 @@
import { Repack } from "@main/entity";
import { decodeNonUtf8Response, savePage } from "./helpers";
import { logger } from "../logger";
import parseTorrent, {
toMagnetURI,
Instance as TorrentInstance,
} from "parse-torrent";
import { JSDOM } from "jsdom";
import { format, parse, sub } from "date-fns";
import { ru } from "date-fns/locale";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
const worker = createWorker({});
import { onlinefixFormatter } from "@main/helpers";
import makeFetchCookie from "fetch-cookie";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
const ONLINE_FIX_URL = "https://online-fix.me/";
let totalPages = 1;
export const getNewRepacksFromOnlineFix = async (
existingRepacks: Repack[] = [],
page = 1,
@ -73,18 +72,16 @@ export const getNewRepacksFromOnlineFix = async (
const repacks: QueryDeepPartialEntity<Repack>[] = [];
const articles = Array.from(document.querySelectorAll(".news"));
const totalPages = Number(
document.querySelector("nav > a:nth-child(13)")?.textContent
);
if (page == 1) {
totalPages = Number(
document.querySelector("nav > a:nth-child(13)")?.textContent
);
}
try {
await Promise.all(
articles.map(async (article) => {
const gameText = article.querySelector("h2.title")?.textContent?.trim();
if (!gameText) return;
const gameName = onlinefixFormatter(gameText);
const gameLink = article.querySelector("a")?.getAttribute("href");
if (!gameLink) return;
@ -93,32 +90,6 @@ export const getNewRepacksFromOnlineFix = async (
);
const gameDocument = new JSDOM(gamePage).window.document;
const uploadDateText = gameDocument.querySelector("time")?.textContent;
if (!uploadDateText) return;
let decodedDateText = uploadDateText;
// "Вчера" means yesterday.
if (decodedDateText.includes("Вчера")) {
const yesterday = sub(new Date(), { days: 1 });
const formattedYesterday = format(yesterday, "d LLLL yyyy", {
locale: ru,
});
decodedDateText = decodedDateText.replace(
"Вчера", // "Change yesterday to the default expected date format"
formattedYesterday
);
}
const uploadDate = parse(
decodedDateText,
"d LLLL yyyy, HH:mm",
new Date(),
{
locale: ru,
}
);
const torrentButtons = Array.from(
gameDocument.querySelectorAll("a")
).filter((a) => a.textContent?.includes("Torrent"));
@ -139,27 +110,27 @@ export const getNewRepacksFromOnlineFix = async (
?.getAttribute("href");
const torrentFile = Buffer.from(
await http(`${torrentPrePage}/${torrentLink}`).then((res) =>
await http(`${torrentPrePage}${torrentLink}`).then((res) =>
res.arrayBuffer()
)
);
const torrent = parseTorrent(torrentFile) as TorrentInstance;
const magnetLink = toMagnetURI({
infoHash: torrent.infoHash,
worker.once("message", (torrent) => {
if (!torrent) return;
const { name, created } = torrent;
repacks.push({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
page: 1,
repacker: "onlinefix",
title: name,
uploadDate: created,
});
});
const torrentSizeInBytes = torrent.length;
if (!torrentSizeInBytes) return;
repacks.push({
fileSize: formatBytes(torrentSizeInBytes),
magnet: magnetLink,
page: 1,
repacker: "onlinefix",
title: gameName,
uploadDate: uploadDate,
});
worker.postMessage(torrentFile);
})
);
} catch (err: unknown) {
@ -177,9 +148,10 @@ export const getNewRepacksFromOnlineFix = async (
);
if (!newRepacks.length) return;
if (page === totalPages) return;
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromOnlineFix(existingRepacks, page + 1, cookieJar);
};

View file

@ -9,8 +9,12 @@ import { toMagnetURI } from "parse-torrent";
import type { Instance } from "parse-torrent";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
import { getFileBuffer } from "@main/helpers";
const worker = createWorker({});
worker.setMaxListeners(11);
let totalPages = 1;
const formatXatabDate = (str: string) => {
const date = new Date();
@ -27,7 +31,7 @@ const formatXatabDate = (str: string) => {
const getXatabRepack = (
url: string
): Promise<{ fileSize: string; magnet: string; uploadDate: Date }> => {
): Promise<{ fileSize: string; magnet: string; uploadDate: Date } | null> => {
return new Promise((resolve) => {
(async () => {
const data = await requestWebPage(url);
@ -40,15 +44,20 @@ const getXatabRepack = (
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) throw new Error("Download button not found");
if (!$downloadButton) return resolve(null);
worker.once("message", (torrent: Instance | null) => {
if (!torrent) return resolve(null);
worker.once("message", (torrent: Instance) => {
resolve({
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
uploadDate: formatXatabDate($uploadDate!.textContent!),
});
});
const buffer = await getFileBuffer($downloadButton.href);
worker.postMessage(buffer);
})();
});
};
@ -63,25 +72,37 @@ export const getNewRepacksFromXatab = async (
const repacks: QueryDeepPartialEntity<Repack>[] = [];
for (const $a of Array.from(
window.document.querySelectorAll(".entry__title a")
)) {
try {
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
repacks.push({
title: $a.textContent!,
repacker: "Xatab",
...repack,
page,
});
} catch (err: unknown) {
logger.error((err as Error).message, {
method: "getNewRepacksFromXatab",
});
}
if (page === 1) {
totalPages = Number(
window.document.querySelector(
"#bottom-nav > div.pagination > a:nth-child(12)"
)?.textContent
);
}
const repacksFromPage = Array.from(
window.document.querySelectorAll(".entry__title a")
).map(($a) => {
return getXatabRepack(($a as HTMLAnchorElement).href)
.then((repack) => {
if (repack) {
repacks.push({
title: $a.textContent!,
repacker: "Xatab",
...repack,
page,
});
}
})
.catch((err: unknown) => {
logger.error((err as Error).message, {
method: "getNewRepacksFromXatab",
});
});
});
await Promise.all(repacksFromPage);
const newRepacks = repacks.filter(
(repack) =>
!existingRepacks.some(
@ -93,5 +114,7 @@ export const getNewRepacksFromXatab = async (
await savePage(newRepacks);
if (page === totalPages) return;
return getNewRepacksFromXatab(existingRepacks, page + 1);
};

View file

@ -1,5 +1,4 @@
import axios from "axios";
import { getSteamAppAsset } from "@main/helpers";
export interface SteamGridResponse {
success: boolean;
@ -59,16 +58,11 @@ export const getSteamGridGameById = async (
return response.data;
};
export const getSteamGameIconUrl = async (objectID: string) => {
export const getSteamGameClientIcon = async (objectID: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectID, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return getSteamAppAsset(
"icon",
objectID,
steamGridGame.data.platforms.steam.metadata.clienticon
);
return steamGridGame.data.platforms.steam.metadata.clienticon;
};

View file

@ -3,107 +3,9 @@ import { app } from "electron";
import { chunk } from "lodash-es";
import { createDataSource, dataSource } from "@main/data-source";
import { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
import {
migrationScriptRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
} from "@main/repository";
import { MigrationScript } from "@main/entity/migration-script.entity";
import { Like } from "typeorm";
const migrationScripts = {
/*
0.0.6 -> 0.0.7
Xatab repacks were previously created with an incorrect upload date.
This migration script will update the upload date of all Xatab repacks.
*/
"0.0.7": async (updateRepacks: Repack[]) => {
const VERSION = "0.0.7";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
const xatabRepacks = updateRepacks.filter(
(repack) => repack.repacker === "Xatab"
);
await dataSource.transaction(async (transactionalEntityManager) => {
await Promise.all(
xatabRepacks.map((repack) =>
transactionalEntityManager.getRepository(Repack).update(
{
title: repack.title,
repacker: repack.repacker,
},
{
uploadDate: repack.uploadDate,
}
)
)
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
/*
1.0.1 -> 1.1.0
A few torrents scraped from 1337x were previously created with an incorrect upload date.
*/
"1.1.0": async () => {
const VERSION = "1.1.0";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
await dataSource.transaction(async (transactionalEntityManager) => {
const repacks = await transactionalEntityManager
.getRepository(Repack)
.find({
where: {
uploadDate: Like("1%"),
},
});
await Promise.all(
repacks.map(async (repack) => {
return transactionalEntityManager
.getRepository(Repack)
.update(
{ id: repack.id },
{ uploadDate: new Date(repack.uploadDate) }
);
})
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
};
export const runMigrationScripts = async (updateRepacks: Repack[]) => {
return Promise.all(
Object.values(migrationScripts).map((migrationScript) => {
return migrationScript(updateRepacks);
})
);
};
import { createDataSource } from "@main/data-source";
import { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
@ -114,25 +16,8 @@ export const resolveDatabaseUpdates = async () => {
return updateDataSource.initialize().then(async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepackerFriendlyNameRepository =
updateDataSource.getRepository(RepackerFriendlyName);
const updateSteamGameRepository = updateDataSource.getRepository(SteamGame);
const [updateRepacks, updateSteamGames, updateRepackerFriendlyNames] =
await Promise.all([
updateRepackRepository.find(),
updateSteamGameRepository.find(),
updateRepackerFriendlyNameRepository.find(),
]);
await runMigrationScripts(updateRepacks);
await repackerFriendlyNameRepository
.createQueryBuilder()
.insert()
.values(updateRepackerFriendlyNames)
.orIgnore()
.execute();
const updateRepacks = await updateRepackRepository.find();
const updateRepacksChunks = chunk(updateRepacks, 800);
@ -144,16 +29,5 @@ export const resolveDatabaseUpdates = async () => {
.orIgnore()
.execute();
}
const steamGamesChunks = chunk(updateSteamGames, 800);
for (const chunk of steamGamesChunks) {
await steamGameRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View file

@ -1,13 +1,24 @@
import { BrowserWindow, Menu, Tray, app } from "electron";
import {
BrowserWindow,
Menu,
MenuItem,
MenuItemConstructorOptions,
Tray,
app,
shell,
} from "electron";
import { is } from "@electron-toolkit/utils";
import { t } from "i18next";
import path from "node:path";
import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset";
import { userPreferencesRepository } from "@main/repository";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { IsNull, Not } from "typeorm";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static splashWindow: Electron.BrowserWindow | null = null;
public static isReadyToShowMainWindow = false;
private static loadURL(hash = "") {
// HMR for renderer base on electron-vite cli.
@ -26,13 +37,51 @@ export class WindowManager {
}
}
private static loadSplashURL() {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.splashWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/splash`
);
} else {
this.splashWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "splash",
}
);
}
}
public static createSplashScreen() {
if (this.splashWindow) return;
this.splashWindow = new BrowserWindow({
width: 380,
height: 380,
frame: false,
resizable: false,
backgroundColor: "#1c1c1c",
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.loadSplashURL();
this.splashWindow.removeMenu();
}
public static createMainWindow() {
// Create the browser window.
if (this.mainWindow || !this.isReadyToShowMainWindow) return;
this.mainWindow = new BrowserWindow({
width: 1200,
height: 720,
minWidth: 1024,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: "hidden",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
@ -66,6 +115,12 @@ export class WindowManager {
});
}
public static prepareMainWindowAndCloseSplash() {
this.isReadyToShowMainWindow = true;
this.splashWindow?.close();
this.createMainWindow();
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadURL(hash);
@ -77,33 +132,66 @@ export class WindowManager {
public static createSystemTray(language: string) {
const tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{
label: t("open", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
const updateSystemTray = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
executablePath: Not(IsNull()),
lastTimePlayed: Not(IsNull()),
},
},
{
label: t("quit", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => app.quit(),
},
]);
take: 5,
order: {
updatedAt: "DESC",
},
});
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({
label: title,
type: "normal",
click: async () => {
if (!executablePath) return;
shell.openPath(executablePath);
},
}));
const contextMenu = Menu.buildFromTemplate([
{
label: t("open", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
},
},
{
type: "separator",
},
...recentlyPlayedGames,
{
type: "separator",
},
{
label: t("quit", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => app.quit(),
},
]);
return contextMenu;
};
tray.setToolTip("Hydra");
tray.setContextMenu(contextMenu);
if (process.platform === "win32" || process.platform === "linux") {
tray.addListener("click", () => {
@ -117,6 +205,11 @@ export class WindowManager {
this.createMainWindow();
});
tray.addListener("right-click", async () => {
const contextMenu = await updateSystemTray();
tray.popUpContextMenu(contextMenu);
});
}
}
}

View file

@ -1,17 +1,14 @@
import type { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
import type { Repack } from "@main/entity";
import type { SteamGame } from "@types";
interface State {
repacks: Repack[];
repackersFriendlyNames: RepackerFriendlyName[];
steamGames: SteamGame[];
eventResults: Map<[string, any[]], any>;
}
const initialState: State = {
repacks: [],
repackersFriendlyNames: [],
steamGames: [],
eventResults: new Map(),
};
export class StateManager {

View file

@ -4,7 +4,6 @@ interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_ONLINEFIX_USERNAME: string;
readonly MAIN_VITE_ONLINEFIX_PASSWORD: string;
readonly MAIN_VITE_SENTRY_DSN: string;
}
interface ImportMeta {

View file

@ -4,13 +4,11 @@ import parseTorrent from "parse-torrent";
const port = parentPort;
if (!port) throw new Error("IllegalState");
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
port.on("message", async (url: string) => {
const buffer = await getFileBuffer(url);
const torrent = await parseTorrent(buffer);
port.postMessage(torrent);
port.on("message", async (buffer: Buffer) => {
try {
const torrent = await parseTorrent(buffer);
port.postMessage(torrent);
} catch (err) {
port.postMessage(null);
}
});

View file

@ -7,6 +7,7 @@ import type {
GameShop,
TorrentProgress,
UserPreferences,
AppUpdaterEvents,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@ -76,8 +77,6 @@ contextBridge.exposeInMainWorld("electron", {
executablePath
),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
getRepackersFriendlyNames: () =>
ipcRenderer.invoke("getRepackersFriendlyNames"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGame: (gameId: number, executablePath: string) =>
@ -114,4 +113,21 @@ contextBridge.exposeInMainWorld("electron", {
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,
/* Splash */
onAutoUpdaterEvent: (cb: (value: AppUpdaterEvents) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: AppUpdaterEvents
) => cb(value);
ipcRenderer.on("autoUpdaterEvent", listener);
return () => {
ipcRenderer.removeListener("autoUpdaterEvent", listener);
};
},
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
continueToMainWindow: () => ipcRenderer.invoke("continueToMainWindow"),
});

View file

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1c">

View file

@ -12,12 +12,11 @@ import {
import * as styles from "./app.css";
import { themeClass } from "./theme.css";
import { useLocation, useNavigate } from "react-router-dom";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
setUserPreferences,
setRepackersFriendlyNames,
toggleDraggingDisabled,
} from "@renderer/features";
import { GameStatusHelper } from "@shared";
@ -28,7 +27,7 @@ export interface AppProps {
children: React.ReactNode;
}
export function App({ children }: AppProps) {
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
@ -45,14 +44,11 @@ export function App({ children }: AppProps) {
);
useEffect(() => {
Promise.all([
window.electron.getUserPreferences(),
window.electron.getRepackersFriendlyNames(),
updateLibrary(),
]).then(([preferences, repackersFriendlyNames]) => {
dispatch(setUserPreferences(preferences));
dispatch(setRepackersFriendlyNames(repackersFriendlyNames));
});
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
dispatch(setUserPreferences(preferences));
}
);
}, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => {
@ -132,7 +128,7 @@ export function App({ children }: AppProps) {
/>
<section ref={contentRef} className={styles.content}>
{children}
<Outlet />
</section>
</article>
</main>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -5,7 +5,6 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg?react";
import * as styles from "./game-card.css";
import { useAppSelector } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface GameCardProps
@ -24,10 +23,6 @@ const shopIcon = {
export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const repackersFriendlyNames = useAppSelector(
(state) => state.repackersFriendlyNames.value
);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
);
@ -47,7 +42,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
<ul className={styles.downloadOptions}>
{uniqueRepackers.map((repacker) => (
<li key={repacker} className={styles.downloadOption}>
<span>{repackersFriendlyNames[repacker]}</span>
<span>{repacker}</span>
</li>
))}
</ul>

View file

@ -42,7 +42,7 @@ export function Modal({
}, [onClose]);
const isTopMostModal = () => {
const openModals = document.querySelectorAll("[role=modal]");
const openModals = document.querySelectorAll("[role=dialog]");
return (
openModals.length &&

View file

@ -106,6 +106,8 @@ export const menuItemButtonLabel = style({
export const gameIcon = style({
width: "20px",
height: "20px",
minWidth: "20px",
minHeight: "20px",
borderRadius: "4px",
backgroundSize: "cover",
});
@ -122,36 +124,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const sidebarFooter = style({
marginTop: "auto",
padding: `${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
});
export const footerSocialsContainer = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT * 1.5}px`,
});
export const footerSocialsItem = style({
color: vars.color.bodyText,
backgroundColor: vars.color.darkBackground,
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
transition: "all ease 0.2s",
cursor: "pointer",
":hover": {
opacity: "0.75",
},
});
export const footerText = style({
color: vars.color.bodyText,
fontSize: "12px",
});

View file

@ -9,14 +9,12 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { routes } from "./routes";
import { MarkGithubIcon } from "@primer/octicons-react";
import TelegramLogo from "@renderer/assets/telegram-icon.svg?react";
import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
const SIDEBAR_MAX_WIDTH = 450;
@ -35,24 +33,6 @@ export function Sidebar() {
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
);
const socials = [
{
url: "https://t.me/hydralauncher",
icon: <TelegramLogo />,
label: t("telegram"),
},
{
url: "https://twitter.com/hydralauncher",
icon: <XLogo />,
label: t("x"),
},
{
url: "https://github.com/hydralauncher/hydra",
icon: <MarkGithubIcon size={16} />,
label: t("github"),
},
];
const location = useLocation();
const { game: gameDownloading, progress } = useDownload();
@ -213,11 +193,16 @@ export function Sidebar() {
handleSidebarItemClick(buildGameDetailsPath(game))
}
>
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
@ -233,26 +218,6 @@ export function Sidebar() {
className={styles.handle}
onMouseDown={handleMouseDown}
/>
<footer className={styles.sidebarFooter}>
<div className={styles.footerText}>{t("follow_us")}</div>
<span className={styles.footerSocialsContainer}>
{socials.map((item) => {
return (
<button
key={item.url}
className={styles.footerSocialsItem}
onClick={() => window.electron.openExternal(item.url)}
title={item.label}
aria-label={item.label}
>
{item.icon}
</button>
);
})}
</span>
</footer>
</aside>
);
}

View file

@ -1,4 +1,5 @@
import type {
AppUpdaterEvents,
CatalogueCategory,
CatalogueEntry,
Game,
@ -62,7 +63,6 @@ declare global {
executablePath: string | null
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
getRepackersFriendlyNames: () => Promise<Record<string, string>>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
@ -91,6 +91,14 @@ declare global {
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;
platform: NodeJS.Platform;
/* Splash */
onAutoUpdaterEvent: (
cb: (event: AppUpdaterEvents) => void
) => () => Electron.IpcRenderer;
checkForUpdates: () => Promise<void>;
restartAndInstallUpdate: () => Promise<void>;
continueToMainWindow: () => Promise<void>;
}
interface Window {

View file

@ -2,7 +2,7 @@ import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { TorrentProgress } from "@types";
interface DownloadState {
export interface DownloadState {
lastPacket: TorrentProgress | null;
gameId: number | null;
gamesWithDeletionInProgress: number[];

View file

@ -1,5 +1,4 @@
export * from "./search-slice";
export * from "./repackers-friendly-names-slice";
export * from "./library-slice";
export * from "./use-preferences-slice";
export * from "./download-slice";

View file

@ -3,7 +3,7 @@ import type { PayloadAction } from "@reduxjs/toolkit";
import type { Game } from "@types";
interface LibraryState {
export interface LibraryState {
value: Game[];
}

View file

@ -1,26 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface RepackersFriendlyNamesState {
value: Record<string, string>;
}
const initialState: RepackersFriendlyNamesState = {
value: {},
};
export const repackersFriendlyNamesSlice = createSlice({
name: "repackersFriendlyNames",
initialState,
reducers: {
setRepackersFriendlyNames: (
state,
action: PayloadAction<RepackersFriendlyNamesState["value"]>
) => {
state.value = action.payload;
},
},
});
export const { setRepackersFriendlyNames } =
repackersFriendlyNamesSlice.actions;

View file

@ -1,7 +1,7 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface SearchState {
export interface SearchState {
value: string;
}

View file

@ -2,7 +2,7 @@ import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { UserPreferences } from "@types";
interface UserPreferencesState {
export interface UserPreferencesState {
value: UserPreferences | null;
}

View file

@ -1,7 +1,7 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface WindowState {
export interface WindowState {
draggingDisabled: boolean;
headerTitle: string;
}

View file

@ -27,7 +27,7 @@ export const getSteamLanguage = (language: string) => {
if (language.startsWith("it")) return "italian";
if (language.startsWith("hu")) return "hungarian";
if (language.startsWith("pl")) return "polish";
if (language.startsWith("zh")) return "chinese";
if (language.startsWith("zh")) return "schinese";
if (language.startsWith("da")) return "danish";
return "english";

View file

@ -6,9 +6,6 @@ import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { HashRouter, Route, Routes } from "react-router-dom";
import { init } from "@sentry/electron/renderer";
import { init as reactInit } from "@sentry/react";
import "@fontsource/fira-mono/400.css";
import "@fontsource/fira-mono/500.css";
import "@fontsource/fira-mono/700.css";
@ -30,21 +27,7 @@ import {
import { store } from "./store";
import * as resources from "@locales";
if (import.meta.env.RENDERER_VITE_SENTRY_DSN) {
init(
{
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await window.electron.getUserPreferences();
if (userPreferences?.telemetryEnabled) return event;
return null;
},
},
reactInit
);
}
import Splash from "./pages/splash/splash";
i18n
.use(LanguageDetector)
@ -64,16 +47,17 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<App>
<Routes>
<Routes>
<Route path="/splash" Component={Splash} />
<Route element={<App />}>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
</Routes>
</App>
</Route>
</Routes>
</HashRouter>
</Provider>
</React.StrictMode>

View file

@ -105,7 +105,7 @@ export function Downloads() {
if (GameStatusHelper.isReady(game?.status)) {
return (
<>
<p>{game?.repack.title}</p>
<p>{game?.repack?.title}</p>
<p>{t("completed")}</p>
</>
);

View file

@ -1,60 +1,15 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ShareAndroidIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import type { ShopDetails } from "@types";
import * as styles from "./game-details.css";
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
export interface DescriptionHeaderProps {
gameDetails: ShopDetails;
}
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
const [clipboardLocked, setClipboardLocked] = useState(false);
const { t, i18n } = useTranslation("game_details");
const { objectID, shop } = useParams();
useEffect(() => {
if (!gameDetails) return setClipboardLocked(true);
setClipboardLocked(false);
}, [gameDetails]);
const handleCopyToClipboard = () => {
if (gameDetails) {
setClipboardLocked(true);
const searchParams = new URLSearchParams({
p: btoa(
JSON.stringify([
objectID,
shop,
encodeURIComponent(gameDetails.name),
i18n.language,
])
),
});
navigator.clipboard.writeText(
OPEN_HYDRA_URL + `/?${searchParams.toString()}`
);
const zero = performance.now();
requestAnimationFrame(function holdLock(time) {
if (time - zero <= 3000) {
requestAnimationFrame(holdLock);
} else {
setClipboardLocked(false);
}
});
}
};
const { t } = useTranslation("game_details");
return (
<div className={styles.descriptionHeader}>
@ -66,21 +21,6 @@ export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
</p>
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
</section>
<Button
theme="outline"
onClick={handleCopyToClipboard}
disabled={clipboardLocked || !gameDetails}
>
{clipboardLocked ? (
t("copied_link_to_clipboard")
) : (
<>
<ShareAndroidIcon />
{t("copy_link_to_clipboard")}
</>
)}
</Button>
</div>
);
}

View file

@ -58,9 +58,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
if (hasMovies && mediaContainerRef.current) {
mediaContainerRef.current.childNodes.forEach((node, index) => {
if (node instanceof HTMLVideoElement) {
if (index == mediaIndex) {
node.play();
} else {
if (index !== mediaIndex) {
node.pause();
}
}

View file

@ -6,7 +6,6 @@ import * as styles from "./game-details.css";
import * as sidebarStyles from "./sidebar/sidebar.css";
import { useTranslation } from "react-i18next";
import { ShareAndroidIcon } from "@primer/octicons-react";
export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details");
@ -29,10 +28,6 @@ export function GameDetailsSkeleton() {
<Skeleton width={145} />
<Skeleton width={150} />
</section>
<Button theme="outline" disabled>
<ShareAndroidIcon />
{t("copy_link_to_clipboard")}
</Button>
</div>
<div className={styles.descriptionSkeleton}>
{Array.from({ length: 3 }).map((_, index) => (

View file

@ -92,6 +92,7 @@ export function GameDetails() {
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setGameDetails(null);
setGame(null);
setIsLoading(true);
setIsGamePlaying(false);

View file

@ -47,10 +47,19 @@ export function HeroPanelActions({
const { t } = useTranslation("game_details");
const getDownloadsPath = async () => {
const userPreferences = await window.electron.getUserPreferences();
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
};
const selectGameExecutable = async () => {
const downloadsPath = await getDownloadsPath();
return window.electron
.showOpenDialog({
properties: ["openFile"],
defaultPath: downloadsPath,
filters: [
{
name: "Game executable",

View file

@ -33,7 +33,7 @@ export function HeroPanelPlaytime({
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 1,
maximumFractionDigits: 0,
});
}, [i18n.language]);

View file

@ -6,7 +6,6 @@ import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { useAppSelector } from "@renderer/hooks";
import { SPACING_UNIT } from "../../theme.css";
import { format } from "date-fns";
import { SelectFolderModal } from "./select-folder-modal";
@ -28,10 +27,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const repackersFriendlyNames = useAppSelector(
(state) => state.repackersFriendlyNames.value
);
const { t } = useTranslation("game_details");
useEffect(() => {
@ -89,7 +84,7 @@ export function RepacksModal({
{repack.title}
</p>
<p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repackersFriendlyNames[repack.repacker]} -{" "}
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate
? format(repack.uploadDate, "dd/MM/yyyy")
: ""}

View file

@ -19,7 +19,6 @@ export function SettingsGeneral({
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
telemetryEnabled: false,
});
useEffect(() => {
@ -28,7 +27,6 @@ export function SettingsGeneral({
downloadsPath,
downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled,
telemetryEnabled,
} = userPreferences;
window.electron.getDefaultDownloadsPath().then((defaultDownloadsPath) => {
@ -37,7 +35,6 @@ export function SettingsGeneral({
downloadsPath: downloadsPath ?? defaultDownloadsPath,
downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled,
telemetryEnabled,
}));
});
}
@ -104,18 +101,6 @@ export function SettingsGeneral({
})
}
/>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
handleChange({
telemetryEnabled: !form.telemetryEnabled,
})
}
/>
</>
);
}

View file

@ -0,0 +1,49 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const main = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 3}px`,
flex: "1",
overflowY: "auto",
alignItems: "center",
});
export const splashIcon = style({
width: "75%",
});
export const updateInfoSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
flex: "1",
overflowY: "auto",
alignItems: "center",
justifyContent: "center",
});
export const progressBar = style({
WebkitAppearance: "none",
appearance: "none",
borderRadius: "4px",
width: "100%",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
height: "18px",
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
transition: "width 0.2s",
},
"::-webkit-progress-bar": {
backgroundColor: vars.color.darkBackground,
},
});
export const progressBarText = style({
zIndex: 2,
});

View file

@ -0,0 +1,82 @@
import icon from "@renderer/assets/icon.png";
import * as styles from "./splash.css";
import { themeClass } from "../../theme.css";
import "../../app.css";
import { useEffect, useState } from "react";
import { AppUpdaterEvents } from "@types";
import { useTranslation } from "react-i18next";
document.body.classList.add(themeClass);
export default function Splash() {
const [status, setStatus] = useState<AppUpdaterEvents | null>(null);
const [newVersion, setNewVersion] = useState("");
const { t } = useTranslation("splash");
useEffect(() => {
const unsubscribe = window.electron.onAutoUpdaterEvent(
(event: AppUpdaterEvents) => {
setStatus(event);
switch (event.type) {
case "error":
window.electron.continueToMainWindow();
break;
case "update-available":
setNewVersion(event.info.version);
break;
case "update-cancelled":
window.electron.continueToMainWindow();
break;
case "update-downloaded":
window.electron.restartAndInstallUpdate();
break;
case "update-not-available":
window.electron.continueToMainWindow();
break;
}
}
);
window.electron.checkForUpdates();
return () => {
unsubscribe();
};
}, []);
const renderUpdateInfo = () => {
switch (status?.type) {
case "download-progress":
return (
<>
<p>{t("downloading_version", { version: newVersion })}</p>
<progress
className={styles.progressBar}
max="100"
value={status.info.percent}
/>
</>
);
case "checking-for-updates":
return <p>{t("searching_updates")}</p>;
case "update-available":
return <p>{t("update_found", { version: newVersion })}</p>;
case "update-downloaded":
return <p>{t("restarting_and_applying")}</p>;
default:
return <></>;
}
};
return (
<main className={styles.main}>
<img src={icon} className={styles.splashIcon} alt="Hydra Launcher Logo" />
<section className={styles.updateInfoSection}>
{renderUpdateInfo()}
</section>
</main>
);
}

View file

@ -3,7 +3,6 @@ import {
downloadSlice,
windowSlice,
librarySlice,
repackersFriendlyNamesSlice,
searchSlice,
userPreferencesSlice,
} from "@renderer/features";
@ -11,7 +10,6 @@ import {
export const store = configureStore({
reducer: {
search: searchSlice.reducer,
repackersFriendlyNames: repackersFriendlyNamesSlice.reducer,
window: windowSlice.reducer,
library: librarySlice.reducer,
userPreferences: userPreferencesSlice.reducer,

View file

@ -1,10 +1,6 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMetaEnv {
readonly RENDERER_VITE_SENTRY_DSN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

Some files were not shown because too many files have changed in this diff Show more