merge branch 'main' of https://github.com/KelvinDiasMoreira/hydra into feature/delete-all-dowload-sources

This commit is contained in:
Kelvin
2025-03-11 22:01:28 -03:00
139 changed files with 3095 additions and 663 deletions

View File

@@ -1,5 +1,9 @@
name: Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:

View File

@@ -1,5 +1,9 @@
name: Lint
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:

View File

@@ -1,5 +1,9 @@
name: Release
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: main

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.1.5",
"version": "3.3.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -36,6 +36,7 @@
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.1.0",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",
@@ -43,6 +44,7 @@
"@sentry/vite-plugin": "^2.22.7",
"auto-launch": "^5.0.6",
"axios": "^1.7.9",
"axios-cookiejar-support": "^5.0.5",
"better-sqlite3": "^11.7.0",
"classic-level": "^2.0.0",
"classnames": "^2.5.1",
@@ -59,6 +61,7 @@
"i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"kill-port": "^2.0.1",
"knex": "^3.1.0",
"lodash-es": "^4.17.21",
"parse-torrent": "^11.0.17",
@@ -69,9 +72,11 @@
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"react-tooltip": "^5.28.0",
"sound-play": "^1.1.0",
"sudo-prompt": "^9.2.1",
"tar": "^7.4.3",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"yaml": "^2.6.1",
"yup": "^1.5.0",

View File

@@ -159,6 +159,8 @@ def action():
downloader = downloads.get(game_id)
if downloader:
downloader.pause_download()
if downloading_game_id == game_id:
downloading_game_id = -1
elif action == 'cancel':
downloader = downloads.get(game_id)

View File

@@ -107,7 +107,10 @@ const copyAria2Macos = async () => {
};
const copyAria2 = () => {
if (fs.existsSync("aria2")) {
const aria2Path =
process.platform === "win32" ? "aria2/aria2c.exe" : "aria2/aria2c";
if (fs.existsSync(aria2Path)) {
console.log("Aria2 already exists, skipping download...");
return;
}

File diff suppressed because one or more lines are too long

View File

@@ -7,18 +7,18 @@
"featured": "مميز",
"surprise_me": "مفاجئني",
"no_results": "لم يتم العثور على نتائج",
"start_typing": "ابدأ الكتابة للبحث...",
"hot": "الأكثر شيوعًا الآن",
"start_typing": "ابدأ بالكتابة للبحث...",
"hot": "الأكثر شهرة الآن",
"weekly": "📅 أفضل ألعاب الأسبوع",
"achievements": "🏆 ألعاب للتغلب عليها"
"achievements": "🏆 ألعاب يجب إكمالها"
},
"sidebar": {
"catalogue": "الكـتالوج",
"catalogue": "الفهرس",
"downloads": "التنزيلات",
"settings": "الإعدادات",
"my_library": "مكتبتي",
"downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)",
"paused": "{{title}} (معلّق)",
"downloading_metadata": "{{title}} (جاري تنزيل البيانات الوصفية...)",
"paused": "{{title}} (معلق)",
"downloading": "{{title}} ({{percentage}} - جاري التنزيل...)",
"filter": "تصفية المكتبة",
"home": "الرئيسية",
@@ -26,12 +26,13 @@
"game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
"sign_in": "تسجيل الدخول",
"friends": "الأصدقاء",
"need_help": "تحتاج مساعدة؟"
"need_help": "تحتاج مساعدة؟",
"favorites": "المفضلة"
},
"header": {
"search": "ابحث عن الألعاب",
"search": "بحث الألعاب",
"home": "الرئيسية",
"catalogue": "الكـتالوج",
"catalogue": "الفهرس",
"downloads": "التنزيلات",
"search_results": "نتائج البحث",
"settings": "الإعدادات",
@@ -40,16 +41,16 @@
},
"bottom_panel": {
"no_downloads_in_progress": "لا توجد تنزيلات قيد التقدم",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية لـ {{title}}...",
"downloading": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...",
"checking_files": "جارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)"
"downloading_metadata": "جاري تنزيل بيانات {{title}} الوصفية...",
"downloading": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": "جاري تنزيل {{title}}... ({{percentage}} مكتمل) - جاري حساب الوقت المتبقي...",
"checking_files": "جاري فحص ملفات {{title}}... ({{percentage}} مكتمل)"
},
"catalogue": {
"search": "تصفية...",
"developers": "المطورون",
"genres": "الأنواع",
"tags": "العلامات",
"tags": "الوسوم",
"publishers": "الناشرون",
"download_sources": "مصادر التنزيل",
"result_count": "{{resultCount}} نتيجة",
@@ -68,34 +69,34 @@
"cancel": "إلغاء",
"remove": "إزالة",
"space_left_on_disk": "{{space}} متبقي على القرص",
"eta": "الانتهاء {{eta}}",
"calculating_eta": "جارٍ حساب الوقت المتبقي...",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"filter": "تصفية الحزم المعاد تعبئتها",
"eta": "الانتهاء المتوقع {{eta}}",
"calculating_eta": "جاري حساب الوقت المتبقي...",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"filter": "تصفية الإصدارات المعادة",
"requirements": "متطلبات النظام",
"minimum": "الحد الأدنى",
"recommended": ُوصى به",
"paused": "معلّق",
"recommended": ستحسن",
"paused": "معلق",
"release_date": "تاريخ الإصدار {{date}}",
"publisher": "نشر بواسطة {{publisher}}",
"hours": "ساعات",
"minutes": "دقائق",
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"amount_hours": "{{amount}} ساعة",
"amount_minutes": "{{amount}} دقيقة",
"accuracy": "دقة {{accuracy}}%",
"add_to_library": "إضافة إلى المكتبة",
"remove_from_library": "إزالة من المكتبة",
"no_downloads": "لا توجد تنزيلات متاحة",
"play_time": "لعب لمدة {{amount}}",
"last_time_played": "آخر تشغيل {{period}}",
"play_time": "وقت اللعب {{amount}}",
"last_time_played": "آخر مرة لعب {{period}}",
"not_played_yet": "لم تلعب {{title}} بعد",
"next_suggestion": "الاقتراح التالي",
"play": "تشغيل",
"deleting": "جارٍ حذف المثبت...",
"deleting": "جاري حذف المثبت...",
"close": "إغلاق",
"playing_now": تم التشغيل الآن",
"playing_now": "جاري التشغيل الآن",
"change": "تغيير",
"repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها",
"repacks_modal_description": "اختر الإصدار المعاد الذي تريد تنزيله",
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات</0>",
"download_now": "تنزيل الآن",
"no_shop_details": "تعذر الحصول على تفاصيل المتجر.",
@@ -110,12 +111,12 @@
"select_executable": "تحديد",
"no_executable_selected": "لم يتم تحديد ملف تشغيل",
"open_folder": "فتح المجلد",
"open_download_location": "عرض الملفات المحملة",
"open_download_location": "عرض الملفات المنزلة",
"create_shortcut": "إنشاء اختصار على سطح المكتب",
"clear": "مسح",
"remove_files": "إزالة الملفات",
"remove_from_library_title": "هل أنت متأكد؟",
"remove_from_library_description": "سيؤدي هذا إلى إزالة {{game}} من مكتبتك",
"remove_from_library_description": "سيتم إزالة {{game}} من مكتبتك",
"options": "خيارات",
"executable_section_title": "ملف التشغيل",
"executable_section_description": "مسار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"",
@@ -123,35 +124,35 @@
"downloads_section_description": "تحقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة",
"danger_zone_section_title": "منطقة الخطر",
"danger_zone_section_description": "إزالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra",
"download_in_progress": "تنزيل قيد التقدم",
"download_in_progress": "جاري التنزيل",
"download_paused": "التنزيل معلق",
"last_downloaded_option": "خيار التنزيل الأخير",
"create_shortcut_success": "تم إنشاء الاختصار بنجاح",
"create_shortcut_error": "خطأ في إنشاء الاختصار",
"nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق",
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟",
"nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يكون مناسبًا لجميع الأعمار. هل تريد المتابعة؟",
"allow_nsfw_content": "متابعة",
"refuse_nsfw_content": "رجوع",
"stats": "الإحصائيات",
"download_count": "مرات التنزيل",
"download_count": "التنزيلات",
"player_count": "اللاعبون النشطون",
"download_error": "خيار التنزيل هذا غير متاح",
"download": "تنزيل",
"executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"",
"warning": "تحذير:",
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.",
"hydra_needs_to_remain_open": "لهذا التنزيل، يجب أن يظل Hydra مفتوحًا حتى اكتماله. إذا تم إغلاق Hydra قبل الاكتمال، ستفقد تقدمك.",
"achievements": "الإنجازات",
"achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "حفظ سحابي",
"cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز",
"cloud_save_description": "احفظ تقدمك في السحابة واستمر في اللعب من أي جهاز",
"backups": "النسخ الاحتياطية",
"install_backup": "تثبيت",
"delete_backup": "حذف",
"create_backup": "نسخة احتياطية جديدة",
"last_backup_date": "آخر نسخة احتياطية في {{date}}",
"no_backup_preview": "لم يتم العثور على حفظات لهذا العنوان",
"restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...",
"uploading_backup": "جارٍ رفع النسخة الاحتياطية...",
"restoring_backup": "جاري استعادة النسخة الاحتياطية ({{progress}} مكتمل)...",
"uploading_backup": "جاري رفع النسخة الاحتياطية...",
"no_backups": "لم تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد",
"backup_uploaded": "تم رفع النسخة الاحتياطية",
"backup_deleted": "تم حذف النسخة الاحتياطية",
@@ -164,61 +165,67 @@
"files_automatically_mapped": "تم تعيين الملفات تلقائيًا",
"no_backups_created": "لم يتم إنشاء نسخ احتياطية لهذه اللعبة",
"manage_files": "إدارة الملفات",
"loading_save_preview": "جارٍ البحث عن حفظات الألعاب...",
"loading_save_preview": "جاري البحث عن حفظات اللعبة...",
"wine_prefix": "بادئة Wine",
"wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة",
"launch_options": "خيارات التشغيل",
"launch_options_description": "يمكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)",
"launch_options_placeholder": م يتم تحديد أي معاملات",
"launch_options_placeholder": ا توجد معلمات محددة",
"no_download_option_info": "لا توجد معلومات متاحة",
"backup_deletion_failed": "فشل حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": "تعرف على كيفية مزامنة إنجازاتك",
"backup_deletion_failed": "فشل في حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": "تم الوصول إلى الحد الأقصى من النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": "شاهد كيفية مزامنة إنجازاتك",
"manage_files_description": "إدارة الملفات التي سيتم نسخها احتياطيًا واستعادتها",
"select_folder": "حدد المجلد",
"backup_from": "نسخة احتياطية من {{date}}",
"custom_backup_location_set": "تم تعيين موقع نسخ احتياطي مخصص",
"no_directory_selected": "لم يتم تحديد مجلد",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا للمزيد من المعلومات.",
"reset_achievements": "إعادة تعيين الإنجازات",
"reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}",
"reset_achievements_title": "هل أنت متأكد؟",
"reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح",
"reset_achievements_error": "فشل إعادة تعيين الإنجازات"
"reset_achievements_error": "فشل في إعادة تعيين الإنجازات",
"download_error_gofile_quota_exceeded": "لقد تجاوزت الحصة الشهرية لـ Gofile. يرجى الانتظار حتى إعادة تعيين الحصة.",
"download_error_real_debrid_account_not_authorized": "حساب Real-Debrid الخاص بك غير مصرح له بإجراء تنزيلات جديدة. يرجى مراجعة إعدادات الحساب والمحاولة مرة أخرى.",
"download_error_not_cached_in_real_debrid": "هذا التنزيل غير متوفر على Real-Debrid وجلب حالة التنزيل من Real-Debrid غير متاح حاليًا.",
"download_error_not_cached_in_torbox": "هذا التنزيل غير متوفر على Torbox وجلب حالة التنزيل من Torbox غير متاح حاليًا.",
"game_removed_from_favorites": "تمت إزالة اللعبة من المفضلة",
"game_added_to_favorites": "تمت إضافة اللعبة إلى المفضلة"
},
"activation": {
"title": "تفعيل Hydra",
"installation_id": "معرف التثبيت:",
"enter_activation_code": "أدخل رمز التفعيل الخاص بك",
"message": "إذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.",
"message": "إذا كنت لا تعرف أين تطلب هذا، فأنت لا يجب أن يكون لديك هذا.",
"activate": "تفعيل",
"loading": "جارٍ التحميل..."
"loading": "جاري التحميل..."
},
"downloads": {
"resume": "استئناف",
"pause": "إيقاف مؤقت",
"eta": "الانتهاء {{eta}}",
"paused": "معلّق",
"verifying": "جارٍ التحقق...",
"eta": "الانتهاء المتوقع {{eta}}",
"paused": "معلق",
"verifying": "جاري التحقق...",
"completed": "مكتمل",
"removed": "غير محمل",
"removed": "غير منزّل",
"cancel": "إلغاء",
"filter": "تصفية الألعاب المحملة",
"filter": "تصفية الألعاب المنزلة",
"remove": "إزالة",
"downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"deleting": "جارٍ حذف المثبت...",
"delete": "إزالة المثبت",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
"deleting": "جاري حذف المثبت...",
"delete": "حذف المثبت",
"delete_modal_title": "هل أنت متأكد؟",
"delete_modal_description": "سيؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك",
"install": "تثبيت",
"download_in_progress": "قيد التقدم",
"queued_downloads": "التنزيلات في قائمة الانتظار",
"downloads_completed": "مكتمل",
"downloads_completed": "مكتملة",
"queued": "في قائمة الانتظار",
"no_downloads_title": "فارغ جدًا",
"no_downloads_title": "لا شيء هنا",
"no_downloads_description": "لم تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.",
"checking_files": "جارٍ فحص الملفات...",
"seeding": "التوزيع",
"checking_files": "جاري فحص الملفات...",
"seeding": "جاري التوزيع",
"stop_seeding": "إيقاف التوزيع",
"resume_seeding": "استئناف التوزيع",
"options": "إدارة"
@@ -228,8 +235,8 @@
"change": "تحديث",
"notifications": "الإشعارات",
"enable_download_notifications": "عند اكتمال التنزيل",
"enable_repack_list_notifications": "عند إضافة حزمة معاد تعبئتها جديدة",
"real_debrid_api_token_label": "رمز واجهة برمجة تطبيقات Real-Debrid",
"enable_repack_list_notifications": "عند إضافة إصدار معاد جديد",
"real_debrid_api_token_label": "رمز Real-Debrid API",
"quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق",
"launch_with_system": "تشغيل Hydra مع بدء النظام",
"general": "عام",
@@ -238,14 +245,14 @@
"language": "اللغة",
"api_token": "رمز API",
"enable_real_debrid": "تفعيل Real-Debrid",
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
"real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، محدودة فقط بسرعة اتصالك بالإنترنت.",
"debrid_invalid_token": "رمز API غير صالح",
"debrid_api_token_hint": "يمكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
"real_debrid_free_account_error": "الحساب \"{{username}}\" حساب مجاني. يرجى الاشتراك في Real-Debrid",
"debrid_linked_message": "تم ربط الحساب \"{{username}}\"",
"save_changes": "حفظ التغييرات",
"changes_saved": "تم حفظ التغييرات بنجاح",
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"download_sources_description": "سيقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL للمصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"validate_download_source": "تحقق",
"remove_download_source": "إزالة",
"removed_download_sources": "تمت إزالة مصادر التنزيل",
@@ -258,7 +265,7 @@
"download_count_zero": "لا توجد خيارات تنزيل",
"download_count_one": "{{countFormatted}} خيار تنزيل",
"download_count_other": "{{countFormatted}} خيارات تنزيل",
"download_source_url": "عنوان URL لمصدر التنزيل",
"download_source_url": "عنوان مصدر التنزيل",
"add_download_source_description": "أدخل عنوان URL لملف .json",
"download_source_up_to_date": "محدث",
"download_source_errored": "خطأ",
@@ -278,13 +285,13 @@
"profile_visibility": "رؤية الملف الشخصي",
"profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك",
"required_field": "هذا الحقل مطلوب",
"source_already_exists": "تمت إضافة هذا المصدر مسبقًا",
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالحًا",
"source_already_exists": "هذا المصدر مضاف مسبقًا",
"must_be_valid_url": "يجب أن يكون المصدر عنوان URL صالح",
"blocked_users": "المستخدمون المحظورون",
"user_unblocked": "تم إلغاء حظر المستخدم",
"enable_achievement_notifications": "عند فتح إنجاز",
"launch_minimized": "تشغيل Hydra مصغرًا",
"disable_nsfw_alert": "تعطيل تنبيه المحتوى غير اللائق",
"disable_nsfw_alert": "تعطيل تنبيهات المحتوى غير اللائق",
"seed_after_download_complete": "التوزيع بعد اكتمال التنزيل",
"show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها",
"account": "الحساب",
@@ -302,18 +309,47 @@
"become_subscriber": "كن مشتركًا في Hydra Cloud",
"subscription_renew_cancelled": "تم تعطيل التجديد التلقائي",
"subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}",
"bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم"
"bill_sent_until": "سيتم إرسال فاتورتك القادمة حتى هذا اليوم",
"no_themes": "يبدو أنه ليس لديك أي سمات بعد، لكن لا تقلق، انقر هنا لإنشاء أول تحفة فنية لك.",
"editor_tab_code": "الكود",
"editor_tab_info": "معلومات",
"editor_tab_save": "حفظ",
"web_store": "المتجر الإلكتروني",
"clear_themes": "مسح",
"create_theme": "إنشاء",
"create_theme_modal_title": "إنشاء سمة مخصصة",
"create_theme_modal_description": "إنشاء سمة جديدة لتخصيص مظهر Hydra",
"theme_name": "الاسم",
"insert_theme_name": "أدخل اسم السمة",
"set_theme": "تعيين السمة",
"unset_theme": "إلغاء تعيين السمة",
"delete_theme": "حذف السمة",
"edit_theme": "تعديل السمة",
"delete_all_themes": "حذف جميع السمات",
"delete_all_themes_description": "سيؤدي هذا إلى حذف جميع السمات المخصصة الخاصة بك",
"delete_theme_description": "سيؤدي هذا إلى حذف السمة {{theme}}",
"cancel": "إلغاء",
"appearance": "المظهر",
"enable_torbox": "تفعيل Torbox",
"torbox_description": "TorBox هي خدمة seedbox متميزة تنافس أفضل الخوادم في السوق.",
"torbox_account_linked": "تم ربط حساب TorBox",
"real_debrid_account_linked": "تم ربط حساب Real-Debrid",
"name_min_length": "يجب أن يكون اسم السمة على الأقل 3 أحرف",
"import_theme": "استيراد سمة",
"import_theme_description": "ستقوم باستيراد {{theme}} من متجر السمات",
"error_importing_theme": "خطأ في استيراد السمة",
"theme_imported": "تم استيراد السمة بنجاح"
},
"notifications": {
"download_complete": "اكتمل التنزيل",
"game_ready_to_install": "{{title}} جاهز للتثبيت",
"repack_list_updated": "تم تحديث قائمة الحزم المعاد تعبئتها",
"repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها",
"repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها",
"repack_list_updated": "تم تحديث قائمة الإصدارات المعادة",
"repack_count_one": "تمت إضافة {{count}} إصدار معاد",
"repack_count_other": "تمت إضافة {{count}} إصدارات معادة",
"new_update_available": "الإصدار {{version}} متوفر",
"restart_to_install_update": "أعد تشغيل Hydra لتثبيت التحديث",
"notification_achievement_unlocked_title": "تم فتح إنجاز لـ {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم"
"notification_achievement_unlocked_body": "{{achievement}} و {{count}} أخرى تم فتحها"
},
"system_tray": {
"open": "فتح Hydra",
@@ -325,7 +361,7 @@
"binary_not_found_modal": {
"title": "البرامج غير مثبتة",
"description": "لم يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك",
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
"instructions": "تحقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة Linux الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
},
"modal": {
"close": "زر الإغلاق"
@@ -334,16 +370,16 @@
"toggle_password_visibility": "تبديل رؤية كلمة المرور"
},
"user_profile": {
"amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دقائق",
"last_time_played": "آخر تشغيل {{period}}",
"amount_hours": "{{amount}} ساعة",
"amount_minutes": "{{amount}} دقيقة",
"last_time_played": "آخر مرة لعب {{period}}",
"activity": "النشاط الأخير",
"library": "المكتبة",
"total_play_time": "إجمالي وقت اللعب",
"no_recent_activity_title": "همم... لا شيء هنا",
"no_recent_activity_title": "لا شيء هنا...",
"no_recent_activity_description": "لم تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!",
"display_name": "اسم العرض",
"saving": "جارٍ الحفظ",
"saving": "جاري الحفظ",
"save": "حفظ",
"edit_profile": "تعديل الملف الشخصي",
"saved_successfully": "تم الحفظ بنجاح",
@@ -352,13 +388,13 @@
"cancel": "إلغاء",
"successfully_signed_out": "تم تسجيل الخروج بنجاح",
"sign_out": "تسجيل الخروج",
"playing_for": "يلعب لمدة {{amount}}",
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"playing_for": "جاري اللعب لمدة {{amount}}",
"sign_out_modal_text": "مكتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"add_friends": "إضافة أصدقاء",
"add": "إضافة",
"friend_code": "رمز الصديق",
"see_profile": "عرض الملف الشخصي",
"sending": "جارٍ الإرسال",
"sending": "جاري الإرسال",
"friend_request_sent": "تم إرسال طلب الصداقة",
"friends": "الأصدقاء",
"friends_list": "قائمة الأصدقاء",
@@ -377,19 +413,19 @@
"blocked_users": "المستخدمون المحظورون",
"unblock": "إلغاء الحظر",
"no_friends_added": "ليس لديك أصدقاء مضافون",
"pending": يد الانتظار",
"pending": "معلق",
"no_pending_invites": "ليس لديك دعوات معلقة",
"no_blocked_users": "ليس لديك مستخدمون محظورون",
"friend_code_copied": "تم نسخ رمز الصديق",
"undo_friendship_modal_text": "سيؤدي هذا إلى إلغاء صداقتك مع {{displayName}}",
"privacy_hint": "لضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات</0>",
"locked_profile": "هذا الملف الشخصي خاص",
"image_process_failure": "فشل معالجة الصورة",
"image_process_failure": "فشل في معالجة الصورة",
"required_field": "هذا الحقل مطلوب",
"displayname_min_length": "يجب أن يكون اسم العرض على الأقل 3 أحرف",
"displayname_max_length": "يجب ألا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "الإبلاغ عن هذا الملف الشخصي",
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟",
"displayname_max_length": "يجب أن لا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "الإبلاغ عن هذا الملف",
"report_reason": "لماذا تقوم بالإبلاغ عن هذا الملف؟",
"report_description": "معلومات إضافية",
"report_description_placeholder": "معلومات إضافية",
"report": "الإبلاغ",
@@ -399,32 +435,32 @@
"report_reason_spam": "بريد عشوائي",
"report_reason_other": "أخرى",
"profile_reported": "تم الإبلاغ عن الملف الشخصي",
"your_friend_code": "رمز صديقك:",
"upload_banner": "تحميل بانر",
"uploading_banner": "جارٍ تحميل البانر...",
"your_friend_code": "رمز الصديق الخاص بك:",
"upload_banner": "رفع بانر",
"uploading_banner": "جاري رفع البانر...",
"background_image_updated": "تم تحديث صورة الخلفية",
"stats": "الإحصائيات",
"achievements": "إنجازات",
"achievements": "الإنجازات",
"games": "الألعاب",
"top_percentile": "ال{{percentile}}% الأعلى",
"top_percentile": "الأعلى {{percentile}}%",
"ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا",
"playing": "يلعب {{game}}",
"playing": "جاري لعب {{game}}",
"achievements_unlocked": "الإنجازات المفتوحة",
"earned_points": "النقاط المكتسبة",
"show_achievements_on_profile": "عرض إنجازاتك على ملفك الشخصي",
"show_points_on_profile": "عرض نقاطك المكتسبة على ملفك الشخصي"
"show_achievements_on_profile": "عرض إنجازاتك في ملفك الشخصي",
"show_points_on_profile": "عرض نقاطك المكتسبة في ملفك الشخصي"
},
"achievement": {
"achievement_unlocked": "تم فتح الإنجاز",
"user_achievements": "إنجازات {{displayName}}",
"your_achievements": "إنجازاتك",
"unlocked_at": "تم الفتح في: {{date}}",
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى",
"subscription_needed": "يحتاج إلى اشتراك Hydra Cloud لعرض هذا المحتوى",
"new_achievements_unlocked": "تم فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات",
"achievements_unlocked_for_game": "تم فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}",
"hidden_achievement_tooltip": "هذا إنجاز مخفي",
"achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز",
"achievement_earn_points": "احصل على {{points}} نقاط مع هذا الإنجاز",
"earned_points": "النقاط المكتسبة:",
"available_points": "النقاط المتاحة:",
"how_to_earn_achievements_points": "كيفية كسب نقاط الإنجازات؟"
@@ -434,10 +470,10 @@
"subscribe_now": "اشترك الآن",
"cloud_saving": "حفظ سحابي",
"cloud_achievements": "احفظ إنجازاتك على السحابة",
"animated_profile_picture": "صورة ملف شخصي متحركة",
"animated_profile_picture": "صورة ملف متحركة",
"premium_support": "دعم ممتاز",
"show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين",
"animated_profile_banner": "بانر ملف شخصي متحرك",
"animated_profile_banner": "بانر ملف متحرك",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "لقد اكتشفت ميزة Hydra Cloud!",
"learn_more": "معرفة المزيد"

View File

@@ -14,8 +14,10 @@
"paused": "{{title}} (Спынена)",
"downloading": "{{title}} ({{percentage}} - Сцягванне…)",
"filter": "Фільтар бібліятэкі",
"home": "Галоўная"
"home": "Галоўная",
"favorites": "Улюбленыя"
},
"header": {
"search": "Пошук",
"home": "Галоўная",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Играта няма избран изпълним файл",
"sign_in": "Вписване",
"friends": "Приятели",
"need_help": "Имате нужда от помощ??"
"need_help": "Имате нужда от помощ??",
"favorites": "Любими игри"
},
"header": {
"search": "Търсене",

View File

@@ -20,10 +20,12 @@
"home": "Inici",
"queued": "{{title}} (En espera)",
"game_has_no_executable": "El joc encara no té un executable seleccionat",
"sign_in": "Entra"
"sign_in": "Entra",
"favorites": "Favorits"
},
"header": {
"search": "Cerca jocs",
"home": "Inici",
"catalogue": "Catàleg",
"downloads": "Baixades",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Hra nemá zvolen žádný spustitelný soubor",
"sign_in": "Přihlásit se",
"friends": "Přátelé",
"need_help": "Potřebujete pomoc?"
"need_help": "Potřebujete pomoc?",
"favorites": "Oblíbené"
},
"header": {
"search": "Vyhledat hry",

View File

@@ -24,10 +24,12 @@
"queued": "{{title}} (I køen)",
"game_has_no_executable": "Spillet har ikke nogen eksekverbar fil valgt",
"sign_in": "Log ind",
"friends": "Venner"
"friends": "Venner",
"favorites": "Favoritter"
},
"header": {
"search": "Søg efter spil",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Downloads",

View File

@@ -20,10 +20,12 @@
"home": "Home",
"queued": "{{title}} (In Warteschlange)",
"game_has_no_executable": "Spiel hat keine ausführbare Datei gewählt",
"sign_in": "Anmelden"
"sign_in": "Anmelden",
"favorites": "Favoriten"
},
"header": {
"search": "Spiele suchen",
"home": "Home",
"catalogue": "Katalog",
"downloads": "Downloads",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"need_help": "Need help?"
"need_help": "Need help?",
"favorites": "Favorites"
},
"header": {
"search": "Search games",
@@ -177,6 +178,8 @@
"manage_files_description": "Manage which files will be backed up and restored",
"select_folder": "Select folder",
"backup_from": "Backup from {{date}}",
"automatic_backup_from": "Automatic backup from {{date}}",
"enable_automatic_cloud_sync": "Enable automatic cloud sync",
"custom_backup_location_set": "Custom backup location set",
"no_directory_selected": "No directory selected",
"no_write_permission": "Cannot download into this directory. Click here to learn more.",
@@ -188,7 +191,9 @@
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
"download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available."
"download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.",
"game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to favorites"
},
"activation": {
"title": "Activate Hydra",
@@ -307,10 +312,35 @@
"subscription_renew_cancelled": "Automatic renewal is disabled",
"subscription_renews_on": "Your subscription renews on {{date}}",
"bill_sent_until": "Your next bill will be sent until this day",
"no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece.",
"editor_tab_code": "Code",
"editor_tab_info": "Info",
"editor_tab_save": "Save",
"web_store": "Web store",
"clear_themes": "Clear",
"create_theme": "Create",
"create_theme_modal_title": "Create custom theme",
"create_theme_modal_description": "Create a new theme to customize Hydra's appearance",
"theme_name": "Name",
"insert_theme_name": "Insert theme name",
"set_theme": "Set theme",
"unset_theme": "Unset theme",
"delete_theme": "Delete theme",
"edit_theme": "Edit theme",
"delete_all_themes": "Delete all themes",
"delete_all_themes_description": "This will delete all your custom themes",
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel",
"appearance": "Appearance",
"enable_torbox": "Enable Torbox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
"real_debrid_account_linked": "Real-Debrid account linked"
"real_debrid_account_linked": "Real-Debrid account linked",
"name_min_length": "Theme name must be at least 3 characters long",
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",
"error_importing_theme": "Error importing theme",
"theme_imported": "Theme imported successfully"
},
"notifications": {
"download_complete": "Download complete",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión",
"friends": "Amigos",
"need_help": "¿Necesitas ayuda?"
"need_help": "¿Necesitas ayuda?",
"favorites": "Favoritos"
},
"header": {
"search": "Buscar juegos",
@@ -173,6 +174,8 @@
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados",
"select_folder": "Seleccionar carpeta",
"backup_from": "Copia de seguridad de {{date}}",
"automatic_backup_from": "Copia de seguridad automática de {{date}}",
"enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube",
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
"clear": "Limpiar",
"no_directory_selected": "No se seleccionó un directorio",
@@ -184,7 +187,13 @@
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}",
"reset_achievements_title": "¿Estás seguro?",
"reset_achievements_success": "Logros reiniciados exitosamente",
"reset_achievements_error": "Se produjo un error al reiniciar los logros"
"reset_achievements_error": "Se produjo un error al reiniciar los logros",
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
"download_error_not_cached_in_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
"download_error_not_cached_in_torbox": "Esta descarga no está disponible en Torbox y el estado de descarga del sondeo aún no está disponible.",
"game_added_to_favorites": "Juego añadido a favoritos",
"game_removed_from_favorites": "Juego removido de favoritos"
},
"activation": {
"title": "Activar Hydra",
@@ -302,7 +311,37 @@
"subscription_renew_cancelled": "Está desactivada la renovación automática",
"subscription_renews_on": "Tú suscripción se renueva el {{date}}",
"update_email": "Actualizar correo",
"update_password": "Actualizar contraseña"
"update_password": "Actualizar contraseña",
"appearance": "Apariencia",
"become_subscriber": "Sé Hydra Cloud",
"cancel": "Cancelar",
"clear_themes": "Limpiar",
"create_theme": "Crear",
"create_theme_modal_description": "Crea un nuevo tema para personalizar la apariencia de Hydra",
"create_theme_modal_title": "Crear tema personalizado",
"delete_all_themes": "Eliminar todos los temas",
"delete_all_themes_description": "Esto eliminará todos tus temas personalizados",
"delete_theme": "Eliminar tema",
"delete_theme_description": "Esto eliminará el tema {{theme}}",
"edit_theme": "Editar tema",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Guardar",
"enable_torbox": "Habilitar Torbox",
"error_importing_theme": "Error al importar el tema",
"import_theme": "Importar tema",
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
"insert_theme_name": "Introducí el nombre del tema",
"name_min_length": "El tema tiene que tener 3 carácteres de largo mínimo",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para crear tu primer tema.",
"real_debrid_account_linked": "Cuenta de Real-Debrid vinculada",
"set_theme": "Establecer tema",
"theme_imported": "Tema importado exitosamente",
"theme_name": "Nombre",
"torbox_account_linked": "Cuenta de TorBox vinculada",
"torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.",
"unset_theme": "Desactivar tema",
"web_store": "Tienda Web"
},
"notifications": {
"download_complete": "Descarga completada",

View File

@@ -25,7 +25,8 @@
"queued": "{{title}} (Järjekorras)",
"game_has_no_executable": "Mängul pole käivitusfaili valitud",
"sign_in": "Logi sisse",
"friends": "Sõbrad"
"friends": "Sõbrad",
"favorites": "Lemmikud"
},
"header": {
"search": "Otsi mänge",

View File

@@ -14,8 +14,10 @@
"paused": "{{title}} (متوقف شده)",
"downloading": "{{title}} ({{percentage}} - در حال دانلود…)",
"filter": "فیلتر کردن کتابخانه",
"home": "خانه"
"home": "خانه",
"favorites": "علاقه‌مندی‌ها"
},
"header": {
"search": "جستجوی بازی‌ها",
"home": "خانه",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (En pause)",
"downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
"filter": "Filtrer la bibliothèque",
"home": "Page daccueil"
"home": "Page daccueil",
"favorites": "Favoris"
},
"header": {
"search": "Recherche",
"catalogue": "Catalogue",
"downloads": "Téléchargements",
"search_results": "Résultats de la recherche",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (Szünet)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"home": "Főoldal"
"home": "Főoldal",
"favorites": "Kedvenc játékok"
},
"header": {
"search": "Keresés",
"home": "Főoldal",
"catalogue": "Katalógus",
"downloads": "Letöltések",

View File

@@ -20,10 +20,12 @@
"home": "Beranda",
"queued": "{{title}} (Antrian)",
"game_has_no_executable": "Game tidak punya file eksekusi yang dipilih",
"sign_in": "Masuk"
"sign_in": "Masuk",
"favorites": "Favorit"
},
"header": {
"search": "Cari game",
"home": "Beranda",
"catalogue": "Katalog",
"downloads": "Unduhan",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (In pausa)",
"downloading": "{{title}} ({{percentage}} - Download…)",
"filter": "Filtra libreria",
"home": "Home"
"home": "Home",
"favorites": "Preferiti"
},
"header": {
"search": "Cerca",
"home": "Home",
"catalogue": "Catalogo",
"downloads": "Download",

View File

@@ -20,8 +20,10 @@
"home": "Басты бет",
"queued": "{{title}} (Кезекте)",
"game_has_no_executable": "Ойынды іске қосу файлы таңдалмаған",
"sign_in": "Кіру"
"sign_in": "Кіру",
"favorites": "Таңдаулылар"
},
"header": {
"search": "Іздеу",
"home": "Басты бет",

View File

@@ -14,8 +14,10 @@
"paused": "{{title}} (일시 정지됨)",
"downloading": "{{title}} ({{percentage}} - 다운로드 중…)",
"filter": "라이브러리 정렬",
"home": "홈"
"home": "홈",
"favorites": "즐겨찾기"
},
"header": {
"search": "게임 검색하기",
"home": "홈",

View File

@@ -24,10 +24,12 @@
"queued": "{{title}} (I køen)",
"game_has_no_executable": "Spillet har ikke noen kjørbar fil valgt",
"sign_in": "Logge inn",
"friends": "Venner"
"friends": "Venner",
"favorites": "Favoritter"
},
"header": {
"search": "Søk efter spill",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Nedlastinger",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (Gepauzeerd)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter Bibliotheek",
"home": "Home"
"home": "Home",
"favorites": "Favorieten"
},
"header": {
"search": "Zoek spellen",
"home": "Home",
"catalogue": "Bibliotheek",
"downloads": "Downloads",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (Zatrzymano)",
"downloading": "{{title}} ({{percentage}} - Pobieranie…)",
"filter": "Filtruj biblioteke",
"home": "Główna"
"home": "Główna",
"favorites": "Ulubione"
},
"header": {
"search": "Szukaj",
"home": "Główna",
"catalogue": "Katalog",
"downloads": "Pobrane",

View File

@@ -26,10 +26,12 @@
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos",
"need_help": "Precisa de ajuda?"
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos"
},
"header": {
"search": "Buscar jogos",
"catalogue": "Catálogo",
"downloads": "Downloads",
"search_results": "Resultados da busca",
@@ -163,6 +165,8 @@
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",
"achievements_not_sync": "Veja como exibir suas conquistas no perfil",
"backup_from": "Backup de {{date}}",
"automatic_backup_from": "Backup automático de {{date}}",
"enable_automatic_cloud_sync": "Habilitar sincronização automática na nuvem",
"custom_backup_location_set": "Localização customizada selecionada",
"select_folder": "Selecione a pasta",
"manage_files_description": "Gerencie quais arquivos serão feitos backup",
@@ -177,7 +181,9 @@
"download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.",
"download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.",
"download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.",
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível."
"download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos"
},
"activation": {
"title": "Ativação",
@@ -296,10 +302,33 @@
"subscription_renew_cancelled": "A renovação automática está desativada",
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
"no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
"editor_tab_save": "Salvar",
"web_store": "Loja de temas",
"clear_themes": "Limpar",
"create_theme": "Criar",
"create_theme_modal_title": "Criar tema customizado",
"create_theme_modal_description": "Criar novo tema para customizar a aparência do Hydra",
"theme_name": "Nome",
"insert_theme_name": "Insira o nome do tema",
"set_theme": "Habilitar tema",
"unset_theme": "Desabilitar tema",
"delete_theme": "Deletar tema",
"edit_theme": "Editar tema",
"delete_all_themes": "Deletar todos os temas",
"delete_all_themes_description": "Isso irá deletar todos os seus temas",
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Aparência",
"enable_torbox": "Habilitar Torbox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
"real_debrid_account_linked": "Conta Real-Debrid associada"
"real_debrid_account_linked": "Conta Real-Debrid associada",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
"error_importing_theme": "Erro ao importar tema",
"theme_imported": "Tema importado com sucesso"
},
"notifications": {
"download_complete": "Download concluído",

View File

@@ -25,10 +25,12 @@
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "O jogo não tem um executável selecionado",
"sign_in": "Iniciar sessão",
"friends": "Amigos"
"friends": "Amigos",
"favorites": "Favoritos"
},
"header": {
"search": "Procurar jogos",
"catalogue": "Catálogo",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",

View File

@@ -14,10 +14,12 @@
"paused": "{{title}} (Pauzat)",
"downloading": "{{title}} ({{percentage}} - Se descarcă...)",
"filter": "Filtrează biblioteca",
"home": "Acasă"
"home": "Acasă",
"favorites": "Favorite"
},
"header": {
"search": "Caută jocuri",
"home": "Acasă",
"catalogue": "Catalog",
"downloads": "Descărcări",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти",
"friends": "Друзья",
"need_help": "Нужна помощь?"
"need_help": "Нужна помощь?",
"favorites": "Избранное"
},
"header": {
"search": "Поиск",
@@ -177,12 +178,20 @@
"manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться",
"select_folder": "Выбрать папку",
"backup_from": "Резервная копия от {{date}}",
"automatic_backup_from": "Автоматическая резервная копия от {{date}}",
"enable_automatic_cloud_sync": "Включить автоматическую синхронизацию в облаке",
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
"no_directory_selected": "Не выбран каталог",
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
"reset_achievements_title": "Вы уверены?",
"reset_achievements_success": "Достижения успешно сброшены",
"reset_achievements_error": "Не удалось сбросить достижения"
"reset_achievements_error": "Не удалось сбросить достижения",
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, а опрос статуса загрузки с Real-Debrid пока недоступен.",
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и опросить статус загрузки с Torbox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного"
},
"activation": {
"title": "Активировать Hydra",
@@ -300,7 +309,36 @@
"become_subscriber": "Станьте обладателем Hydra Cloud",
"subscription_renew_cancelled": "Автоматическое продление отключено",
"subscription_renews_on": "Ваша подписка продлевается на {{date}}",
"bill_sent_until": "Ваш следующий счет будет отправлен до этого дня"
"bill_sent_until": "Ваш следующий счет будет отправлен до этого дня",
"no_themes": "Похоже, что у вас еще нет тем, но не волнуйтесь, нажмите здесь, чтобы создать свой первый шедевр",
"editor_tab_code": "Код",
"editor_tab_info": "Информация",
"editor_tab_save": "Сохранить",
"web_store": "Веб-магазин",
"clear_themes": "Очистить",
"create_theme": "Создать",
"create_theme_modal_title": "Создать пользовательскую тему",
"create_theme_modal_description": "Создать новую тему для настройки внешнего вида Hydra",
"theme_name": "Название",
"insert_theme_name": "Вставить название темы",
"set_theme": "Установить тему",
"unset_theme": "Снять тему",
"delete_theme": "Удалить тему",
"edit_theme": "Редактировать тему",
"delete_all_themes": "Удалить все темы",
"delete_all_themes_description": "Это удалит все ваши пользовательские темы",
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
"cancel": "Отменить",
"appearance": "Внешний вид",
"enable_torbox": "Включить Torbox",
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
"torbox_account_linked": "Аккаунт TorBox привязан",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
"error_importing_theme": "Ошибка при импорте темы",
"theme_imported": "Тема успешно импортирована"
},
"notifications": {
"download_complete": "Загрузка завершена",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Oyun için bir çalıştırılabilir dosya seçilmedi",
"sign_in": "Giriş yap",
"friends": "Arkadaşlar",
"need_help": "Yardıma mı ihtiyacınız var?"
"need_help": "Yardıma mı ihtiyacınız var?",
"favorites": "Favoriler"
},
"header": {
"search": "Oyunları ara",

View File

@@ -20,10 +20,12 @@
"home": "Головна",
"game_has_no_executable": "Не було вибрано файл для запуску гри",
"queued": "{{title}} в черзі",
"sign_in": "Увійти"
"sign_in": "Увійти",
"favorites": "Улюблені"
},
"header": {
"search": "Пошук",
"home": "Головна",
"catalogue": "Каталог",
"downloads": "Завантаження",

View File

@@ -25,7 +25,8 @@
"queued": "{{title}} (已加入下载队列)",
"game_has_no_executable": "未选择游戏的可执行文件",
"sign_in": "登入",
"friends": "好友"
"friends": "好友",
"favorites": "收藏"
},
"header": {
"search": "搜索游戏",

View File

@@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { Crypto } from "@main/services";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, {
@@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
});
if (!auth) return null;
const payload = jwt.decode(
Crypto.decrypt(auth.accessToken)
) as jwt.JwtPayload;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
if (!payload) return null;

View File

@@ -1,44 +1,8 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import { CloudSync } from "@main/services";
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import * as tar from "tar";
import crypto from "node:crypto";
import type { GameShop } from "@types";
import axios from "axios";
import os from "node:os";
import { backupsPath } from "@main/constants";
import { app } from "electron";
import { normalizePath } from "@main/helpers";
import { gamesSublevel, levelKeys } from "@main/level";
const bundleBackup = async (
shop: GameShop,
objectId: string,
winePrefix: string | null
) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
// Remove existing backup
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, { recursive: true });
}
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
return tarLocation;
};
import { t } from "i18next";
import { format } from "date-fns";
const uploadSaveGame = async (
_event: Electron.IpcMainInvokeEvent,
@@ -46,61 +10,15 @@ const uploadSaveGame = async (
shop: GameShop,
downloadOptionTitle: string | null
) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const bundleLocation = await bundleBackup(
shop,
return CloudSync.uploadSaveGame(
objectId,
game?.winePrefixPath ?? null
shop,
downloadOptionTitle,
t("backup_from", {
ns: "game_details",
date: format(new Date(), "dd/MM/yyyy"),
})
);
fs.stat(bundleLocation, async (err, stat) => {
if (err) {
logger.error("Failed to get zip file stats", err);
throw err;
}
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: normalizePath(app.getPath("home")),
downloadOptionTitle,
platform: os.platform(),
});
fs.readFile(bundleLocation, async (err, fileBuffer) => {
if (err) {
logger.error("Failed to read zip file", err);
throw err;
}
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
logger.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to remove tar file", err);
throw err;
}
});
});
});
};
registerEvent("uploadSaveGame", uploadSaveGame);

View File

@@ -13,6 +13,8 @@ import "./catalogue/get-developers";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
import "./library/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
@@ -29,11 +31,13 @@ import "./library/remove-game";
import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements";
import "./library/toggle-automatic-cloud-sync";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./misc/get-features";
import "./misc/show-item-in-folder";
import "./misc/get-badges";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
@@ -56,6 +60,7 @@ import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-auth";
import "./user/get-user-stats";
import "./user/report-user";
import "./user/get-unlocked-achievements";
@@ -75,6 +80,16 @@ import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";
import "./themes/delete-all-custom-themes";
import "./themes/update-custom-theme";
import "./themes/open-editor-window";
import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@@ -0,0 +1,25 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const addGameToFavorites = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
favorite: true,
});
} catch (error) {
throw new Error(`Failed to update game favorite status: ${error}`);
}
};
registerEvent("addGameToFavorites", addGameToFavorites);

View File

@@ -5,7 +5,7 @@ import type { Game, GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async (
@@ -46,9 +46,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game);
await createGame(game).catch(() => {});
createGame(game).catch(() => {});
updateLocalUnlockedAchievements(game);
}
};

View File

@@ -0,0 +1,25 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const removeGameFromFavorites = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
favorite: false,
});
} catch (error) {
throw new Error(`Failed to update game favorite status: ${error}`);
}
};
registerEvent("removeGameFromFavorites", removeGameFromFavorites);

View File

@@ -0,0 +1,23 @@
import { registerEvent } from "../register-event";
import { levelKeys, gamesSublevel } from "@main/level";
import type { GameShop } from "@types";
const toggleAutomaticCloudSync = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
automaticCloudSync,
});
};
registerEvent("toggleAutomaticCloudSync", toggleAutomaticCloudSync);

View File

@@ -0,0 +1,22 @@
import { Badge } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
})
.then((language) => language || "en");
const params = new URLSearchParams({
locale: language,
});
return HydraApi.get<Badge[]>(`/badges?${params.toString()}`, null, {
needsAuth: false,
});
};
registerEvent("getBadges", getBadges);

View File

@@ -1,6 +1,6 @@
import { shell } from "electron";
import { registerEvent } from "../register-event";
import { Crypto, HydraApi } from "@main/services";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
@@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
}
const paymentToken = await HydraApi.post("/auth/payment", {
refreshToken: Crypto.decrypt(auth.refreshToken),
refreshToken: auth.refreshToken,
}).then((response) => response.accessToken);
const params = new URLSearchParams({

View File

@@ -0,0 +1,12 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View File

@@ -0,0 +1,11 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const closeEditorWindow = async (
_event: Electron.IpcMainInvokeEvent,
themeId?: string
) => {
WindowManager.closeEditorWindow(themeId);
};
registerEvent("closeEditorWindow", closeEditorWindow);

View File

@@ -0,0 +1,8 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
await themesSublevel.clear();
};
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);

View File

@@ -0,0 +1,11 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
await themesSublevel.del(themeId);
};
registerEvent("deleteCustomTheme", deleteCustomTheme);

View File

@@ -0,0 +1,9 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getActiveCustomTheme = async () => {
const allThemes = await themesSublevel.values().all();
return allThemes.find((theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View File

@@ -0,0 +1,8 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
return themesSublevel.values().all();
};
registerEvent("getAllCustomThemes", getAllCustomThemes);

View File

@@ -0,0 +1,11 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
return themesSublevel.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View File

@@ -0,0 +1,11 @@
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const openEditorWindow = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
WindowManager.openEditorWindow(themeId);
};
registerEvent("openEditorWindow", openEditorWindow);

View File

@@ -0,0 +1,22 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const toggleCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
isActive: boolean
) => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
await themesSublevel.put(themeId, {
...theme,
isActive,
updatedAt: new Date(),
});
};
registerEvent("toggleCustomTheme", toggleCustomTheme);

View File

@@ -0,0 +1,27 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const updateCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
code: string
) => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
await themesSublevel.put(themeId, {
...theme,
code,
updatedAt: new Date(),
});
if (theme.isActive) {
WindowManager.mainWindow?.webContents.send("css-injected", code);
}
};
registerEvent("updateCustomTheme", updateCustomTheme);

View File

@@ -13,7 +13,14 @@ const cancelGameDownload = async (
await DownloadManager.cancelDownload(downloadKey);
await downloadsSublevel.del(downloadKey);
const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(downloadKey, {
...download,
status: "removed",
});
};
registerEvent("cancelGameDownload", cancelGameDownload);

View File

@@ -15,6 +15,7 @@ const pauseGameSeed = async (
await downloadsSublevel.put(downloadKey, {
...download,
status: "complete",
shouldSeed: false,
});

View File

@@ -8,12 +8,14 @@ const resumeGameSeed = async (
shop: GameShop,
objectId: string
) => {
const download = await downloadsSublevel.get(levelKeys.game(shop, objectId));
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(levelKeys.game(shop, objectId), {
await downloadsSublevel.put(downloadKey, {
...download,
status: "seeding",
shouldSeed: true,
});

View File

@@ -1,27 +1,10 @@
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import type { UserPreferences } from "@types";
const getUserPreferences = async () =>
db
.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
})
.then((userPreferences) => {
if (userPreferences?.realDebridApiToken) {
userPreferences.realDebridApiToken = Crypto.decrypt(
userPreferences.realDebridApiToken
);
}
if (userPreferences?.torBoxApiToken) {
userPreferences.torBoxApiToken = Crypto.decrypt(
userPreferences.torBoxApiToken
);
}
return userPreferences;
});
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
registerEvent("getUserPreferences", getUserPreferences);

View File

@@ -3,7 +3,6 @@ import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
import i18next from "i18next";
import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async (
@@ -24,16 +23,6 @@ const updateUserPreferences = async (
patchUserProfile({ language: preferences.language }).catch(() => {});
}
if (preferences.realDebridApiToken) {
preferences.realDebridApiToken = Crypto.encrypt(
preferences.realDebridApiToken
);
}
if (preferences.torBoxApiToken) {
preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken);
}
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}

View File

@@ -0,0 +1,11 @@
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { registerEvent } from "../register-event";
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
registerEvent("getAuth", getAuth);

View File

@@ -32,7 +32,7 @@ export const getUnlockedAchievements = async (
return achievementsData
.map((achievementData) => {
const unlockedAchiementData = unlockedAchievements.find(
const unlockedAchievementData = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
@@ -45,11 +45,11 @@ export const getUnlockedAchievements = async (
? achievementData.icon
: achievementData.icongray;
if (unlockedAchiementData) {
if (unlockedAchievementData) {
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchiementData.unlockTime,
unlockTime: unlockedAchievementData.unlockTime,
};
}

View File

@@ -3,6 +3,7 @@ import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import kill from "kill-port";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services";
import resources from "@locales";
@@ -58,7 +59,7 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
await loadState();
await kill(PythonRPC.RPC_PORT).finally(() => loadState());
const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
@@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
return;
}
if (url.host === "profile") {
const userId = url.searchParams.get("userId");
if (userId) {
WindowManager.redirect(`profile/${userId}`);
}
return;
}
if (url.host === "install-theme") {
const themeName = url.searchParams.get("theme");
const authorId = url.searchParams.get("authorId");
const authorName = url.searchParams.get("authorName");
if (themeName && authorId && authorName) {
WindowManager.redirect(
`settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}`
);
}
}
} catch (error) {
logger.error("Error handling deep link", uri, error);

View File

@@ -3,3 +3,4 @@ export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";
export * from "./themes";

View File

@@ -5,6 +5,7 @@ export const levelKeys = {
game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
user: "user",
auth: "auth",
themes: "themes",
gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`,
@@ -13,4 +14,5 @@ export const levelKeys = {
userPreferences: "userPreferences",
language: "language",
sqliteMigrationDone: "sqliteMigrationDone",
screenState: "screenState",
};

View File

@@ -0,0 +1,7 @@
import type { Theme } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const themesSublevel = db.sublevel<string, Theme>(levelKeys.themes, {
valueEncoding: "json",
});

View File

@@ -1,10 +1,4 @@
import {
Crypto,
DownloadManager,
logger,
Ludusavi,
startMainLoop,
} from "./services";
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services";
import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
@@ -38,13 +32,11 @@ export const loadState = async () => {
Aria2.spawn();
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(
Crypto.decrypt(userPreferences.realDebridApiToken)
);
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
if (userPreferences?.torBoxApiToken) {
TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken));
TorBoxClient.authorize(userPreferences.torBoxApiToken);
}
Ludusavi.addManifestToLudusaviConfig();
@@ -57,21 +49,17 @@ export const loadState = async () => {
.values()
.all()
.then((games) => {
return sortBy(
games.filter((game) => game.queued),
"timestamp",
"DESC"
);
return sortBy(games, "timestamp", "DESC");
});
const [nextItemOnQueue] = downloads;
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
const downloadsToSeed = downloads.filter(
(download) =>
download.shouldSeed &&
download.downloader === Downloader.Torrent &&
download.progress === 1 &&
download.uri !== null
(game) =>
game.shouldSeed &&
game.downloader === Downloader.Torrent &&
game.progress === 1 &&
game.uri !== null
);
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
@@ -123,9 +111,7 @@ const migrateFromSqlite = async () => {
levelKeys.userPreferences,
{
...rest,
realDebridApiToken: realDebridApiToken
? Crypto.encrypt(realDebridApiToken)
: null,
realDebridApiToken,
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
runAtStartup: rest.runAtStartup === 1,
startMinimized: rest.startMinimized === 1,
@@ -144,7 +130,9 @@ const migrateFromSqlite = async () => {
);
if (rest.language) {
await db.put(levelKeys.language, rest.language);
await db.put<string, string>(levelKeys.language, rest.language, {
valueEncoding: "utf-8",
});
}
}
})
@@ -191,8 +179,8 @@ const migrateFromSqlite = async () => {
await db.put<string, Auth>(
levelKeys.auth,
{
accessToken: Crypto.encrypt(users[0].accessToken),
refreshToken: Crypto.encrypt(users[0].refreshToken),
accessToken: users[0].accessToken,
refreshToken: users[0].refreshToken,
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
},
{

View File

@@ -23,23 +23,21 @@ const saveAchievementsOnLocal = async (
return gameAchievementsSublevel
.get(levelKey)
.then(async (gameAchievement) => {
if (gameAchievement) {
await gameAchievementsSublevel.put(levelKey, {
...gameAchievement,
unlockedAchievements: unlockedAchievements,
});
await gameAchievementsSublevel.put(levelKey, {
achievements: gameAchievement?.achievements ?? [],
unlockedAchievements: unlockedAchievements,
});
if (!sendUpdateEvent) return;
if (!sendUpdateEvent) return;
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
}
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
});
};
@@ -133,7 +131,7 @@ export const mergeAchievements = async (
);
})
.catch((err) => {
if (err! instanceof SubscriptionRequiredError) {
if (err instanceof SubscriptionRequiredError) {
achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription",
game.objectId,

View File

@@ -6,7 +6,7 @@ import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchivements = async (game: Game) => {
export const updateLocalUnlockedAchievements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =

View File

@@ -0,0 +1,112 @@
import { levelKeys, gamesSublevel, db } from "@main/level";
import { app } from "electron";
import path from "node:path";
import * as tar from "tar";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import type { GameShop, User } from "@types";
import { backupsPath } from "@main/constants";
import { HydraApi } from "./hydra-api";
import { normalizePath } from "@main/helpers";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import axios from "axios";
import { Ludusavi } from "./ludusavi";
import { isFuture, isToday } from "date-fns";
import { SubscriptionRequiredError } from "@shared";
export class CloudSync {
private static async bundleBackup(
shop: GameShop,
objectId: string,
winePrefix: string | null
) {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
// Remove existing backup
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, { recursive: true });
}
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
return tarLocation;
}
public static async uploadSaveGame(
objectId: string,
shop: GameShop,
downloadOptionTitle: string | null,
label?: string
) {
const hasActiveSubscription = await db
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
.then((user) => {
const expiresAt = user?.subscription?.expiresAt;
return expiresAt && (isFuture(expiresAt) || isToday(expiresAt));
});
if (!hasActiveSubscription) {
throw new SubscriptionRequiredError();
}
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const bundleLocation = await this.bundleBackup(
shop,
objectId,
game?.winePrefixPath ?? null
);
const stat = await fs.promises.stat(bundleLocation);
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: normalizePath(app.getPath("home")),
downloadOptionTitle,
platform: os.platform(),
label,
});
const fileBuffer = await fs.promises.readFile(bundleLocation);
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
logger.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to remove tar file", err);
throw err;
}
});
}
}

View File

@@ -1,28 +0,0 @@
import { safeStorage } from "electron";
import { logger } from "./logger";
export class Crypto {
public static encrypt(str: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.encryptString(str).toString("base64");
} else {
logger.warn(
"Encrypt method returned raw string because encryption is not available"
);
return str;
}
}
public static decrypt(b64: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(Buffer.from(b64, "base64"));
} else {
logger.warn(
"Decrypt method returned raw string because encryption is not available"
);
return b64;
}
}
}

View File

@@ -2,7 +2,13 @@ import { Downloader, DownloadError } from "@shared";
import { WindowManager } from "../window-manager";
import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types";
import { GofileApi, QiwiApi, DatanodesApi, MediafireApi } from "../hosters";
import {
GofileApi,
QiwiApi,
DatanodesApi,
MediafireApi,
PixelDrainApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
LibtorrentPayload,
@@ -219,8 +225,10 @@ export class DownloadManager {
} as PauseDownloadPayload)
.catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
}
}
static async resumeDownload(download: Download) {
@@ -228,14 +236,17 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", {
action: "cancel",
game_id: downloadKey,
});
WindowManager.mainWindow?.setProgressBar(-1);
await PythonRPC.rpc
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
}
@@ -278,11 +289,12 @@ export class DownloadManager {
}
case Downloader.PixelDrain: {
const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
return {
action: "start",
game_id: downloadId,
url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`,
url: downloadUrl,
save_path: download.downloadPath,
};
}

View File

@@ -6,6 +6,7 @@ import type {
TorBoxAddTorrentRequest,
TorBoxRequestLinkRequest,
} from "@types";
import { appVersion } from "@main/constants";
export class TorBoxClient {
private static instance: AxiosInstance;
@@ -18,6 +19,7 @@ export class TorBoxClient {
baseURL: this.baseURL,
headers: {
Authorization: `Bearer ${apiToken}`,
"User-Agent": `Hydra/${appVersion}`,
},
});
}

View File

@@ -1,47 +1,71 @@
import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
export class DatanodesApi {
private static readonly session = axios.create({});
private static readonly jar = new CookieJar();
private static readonly session = wrapper(
axios.create({
jar: DatanodesApi.jar,
withCredentials: true,
})
);
public static async getDownloadUrl(downloadUrl: string): Promise<string> {
const parsedUrl = new URL(downloadUrl);
const pathSegments = parsedUrl.pathname.split("/");
try {
const parsedUrl = new URL(downloadUrl);
const pathSegments = parsedUrl.pathname.split("/").filter(Boolean);
const fileCode = pathSegments[0];
const fileCode = decodeURIComponent(pathSegments[1]);
const fileName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
rand: "",
referer: "https://datanodes.to/download",
method_free: "Free Download >>",
method_premium: "",
adblock_detected: "",
});
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
method_free: "Free Download >>",
dl: "1",
});
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: `lang=english; file_name=${fileName}; file_code=${fileCode};`,
Host: "datanodes.to",
Origin: "https://datanodes.to",
Referer: "https://datanodes.to/download",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to",
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
}
);
if (response.status === 302) {
return response.headers["location"];
}
);
if (response.status === 302) {
return response.headers["location"];
if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(response.data.url);
}
const htmlContent = String(response.data);
if (!htmlContent) {
throw new Error("Empty response received");
}
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
if (downloadLinkMatch) {
return downloadLinkMatch[1];
}
throw new Error("Failed to get the download link");
} catch (error) {
console.error("Error fetching download URL:", error);
throw error;
}
return "";
}
}

View File

@@ -2,3 +2,4 @@ export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";

View File

@@ -0,0 +1,42 @@
import axios from "axios";
export class PixelDrainApi {
private static readonly browserHeaders = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
DNT: "1",
Connection: "keep-alive",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
};
public static async getDownloadUrl(fileId: string): Promise<string> {
try {
const response = await axios.get(`https://pd.cybar.xyz/${fileId}`, {
headers: this.browserHeaders,
maxRedirects: 0,
validateStatus: (status) =>
status === 301 || status === 302 || status === 200,
});
if (
response.headers.location ||
response.status === 301 ||
response.status === 302
) {
return response.headers.location;
}
throw new Error(`No redirect URL found (status: ${response.status})`);
} catch (error) {
console.error("Error fetching PixelDrain URL:", error);
throw error;
}
}
}

View File

@@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -32,8 +31,9 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly secondsToMilliseconds = (seconds: number) =>
seconds * 1000;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
}
private static userAuth: HydraApiUserAuth = {
authToken: "",
@@ -81,8 +81,8 @@ export class HydraApi {
db.put<string, Auth>(
levelKeys.auth,
{
accessToken: Crypto.encrypt(accessToken),
refreshToken: Crypto.encrypt(refreshToken),
accessToken,
refreshToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }
@@ -204,12 +204,8 @@ export class HydraApi {
const user = result.at(1) as User | undefined;
this.userAuth = {
authToken: userAuth?.accessToken
? Crypto.decrypt(userAuth.accessToken)
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
authToken: userAuth?.accessToken ?? "",
refreshToken: userAuth?.refreshToken ?? "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: user?.subscription
? { expiresAt: user.subscription?.expiresAt }
@@ -258,7 +254,7 @@ export class HydraApi {
levelKeys.auth,
{
...auth,
accessToken: Crypto.encrypt(accessToken),
accessToken,
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }

View File

@@ -1,4 +1,3 @@
export * from "./crypto";
export * from "./logger";
export * from "./steam";
export * from "./steam-250";
@@ -8,3 +7,4 @@ export * from "./process-watcher";
export * from "./main-loop";
export * from "./hydra-api";
export * from "./ludusavi";
export * from "./cloud-sync";

View File

@@ -24,7 +24,7 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...localGame,
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
@@ -39,7 +39,7 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,

View File

@@ -6,6 +6,9 @@ import axios from "axios";
import { exec } from "child_process";
import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { t } from "i18next";
import { CloudSync } from "./cloud-sync";
import { format } from "date-fns";
const commands = {
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
@@ -225,6 +228,18 @@ function onOpenGame(game: Game) {
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()).catch(() => {});
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
game.objectId,
game.shop,
null,
t("automatic_backup_from", {
ns: "game_details",
date: format(new Date(), "dd/MM/yyyy"),
})
);
}
} else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
}
@@ -287,6 +302,18 @@ const onCloseGame = (game: Game) => {
performance.now() - gamePlaytime.lastSyncTick,
game.lastTimePlayed!
).catch(() => {});
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
game.objectId,
game.shop,
null,
t("automatic_backup_from", {
ns: "game_details",
date: format(new Date(), "dd/MM/yyyy"),
})
);
}
} else {
createGame(game).catch(() => {});
}

View File

@@ -17,13 +17,37 @@ import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { slice, sortBy } from "lodash-es";
import type { UserPreferences } from "@types";
import type { ScreenState, UserPreferences } from "@types";
import { AuthPage } from "@shared";
import { isStaging } from "@main/constants";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
height: 720,
minWidth: 1024,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#00000000",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
};
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
@@ -41,29 +65,51 @@ export class WindowManager {
}
}
public static createMainWindow() {
private static async saveScreenConfig({
...configScreenWhenClosed
}: {
x: number | undefined;
y: number | undefined;
width: number;
height: number;
isMaximized: boolean;
}) {
await db.put(levelKeys.screenState, configScreenWhenClosed, {
valueEncoding: "json",
});
}
private static async loadScreenConfig() {
const data = await db.get<string, ScreenState>(levelKeys.screenState, {
valueEncoding: "json",
});
return data ?? {};
}
private static updateInitialConfig(
newConfig: Partial<Electron.BrowserWindowConstructorOptions>
) {
this.initialConfigInitializationMainWindow = {
...this.initialConfigInitializationMainWindow,
...newConfig,
};
}
public static async createMainWindow() {
if (this.mainWindow) return;
this.mainWindow = new BrowserWindow({
width: 1200,
height: 720,
minWidth: 1024,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
const { isMaximized = false, ...configWithoutMaximized } =
await this.loadScreenConfig();
this.updateInitialConfig(configWithoutMaximized);
this.mainWindow = new BrowserWindow(
this.initialConfigInitializationMainWindow
);
if (isMaximized) {
this.mainWindow.maximize();
}
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
@@ -141,6 +187,22 @@ export class WindowManager {
}
);
if (this.mainWindow) {
const lastBounds = this.mainWindow.getBounds();
const isMaximized = this.mainWindow.isMaximized() ?? false;
const screenConfig = isMaximized
? {
x: undefined,
y: undefined,
height: this.initialConfigInitializationMainWindow.height!,
width: this.initialConfigInitializationMainWindow.width!,
isMaximized: true,
}
: { ...lastBounds, isMaximized };
await this.saveScreenConfig(screenConfig);
}
if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit();
}
@@ -201,6 +263,87 @@ export class WindowManager {
}
}
public static openEditorWindow(themeId: string) {
if (this.mainWindow) {
const existingWindow = this.editorWindows.get(themeId);
if (existingWindow) {
if (existingWindow.isMinimized()) {
existingWindow.restore();
}
existingWindow.focus();
return;
}
const editorWindow = new BrowserWindow({
width: 600,
height: 720,
minWidth: 600,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.editorWindows.set(themeId, editorWindow);
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash: `theme-editor?themeId=${themeId}`,
});
}
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (isStaging) {
editorWindow.webContents.openDevTools();
}
});
editorWindow.webContents.on("before-input-event", (event, input) => {
if (input.key === "F12") {
event.preventDefault();
this.mainWindow?.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}
}
public static closeEditorWindow(themeId?: string) {
if (themeId) {
const editorWindow = this.editorWindows.get(themeId);
if (editorWindow) {
editorWindow.close();
}
} else {
this.editorWindows.forEach((editorWindow) => {
editorWindow.close();
});
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);

View File

@@ -14,6 +14,7 @@ import type {
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
Theme,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
@@ -100,6 +101,17 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("putDownloadSource", objectIds),
/* Library */
toggleAutomaticCloudSync: (
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) =>
ipcRenderer.invoke(
"toggleAutomaticCloudSync",
shop,
objectId,
automaticCloudSync
),
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
createGameShortcut: (shop: GameShop, objectId: string) =>
@@ -110,11 +122,16 @@ contextBridge.exposeInMainWorld("electron", {
executablePath: string | null
) =>
ipcRenderer.invoke("updateExecutablePath", shop, objectId, executablePath),
addGameToFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
updateLaunchOptions: (
shop: GameShop,
objectId: string,
launchOptions: string | null
) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions),
selectGameWinePrefix: (
shop: GameShop,
objectId: string,
@@ -260,6 +277,7 @@ contextBridge.exposeInMainWorld("electron", {
showItemInFolder: (path: string) =>
ipcRenderer.invoke("showItemInFolder", path),
getFeatures: () => ipcRenderer.invoke("getFeatures"),
getBadges: () => ipcRenderer.invoke("getBadges"),
platform: process.platform,
/* Auto update */
@@ -319,6 +337,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"),
signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page),
@@ -342,4 +361,30 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
/* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
/* Editor */
openEditorWindow: (themeId: string) =>
ipcRenderer.invoke("openEditorWindow", themeId),
onCssInjected: (cb: (cssString: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, cssString: string) =>
cb(cssString);
ipcRenderer.on("css-injected", listener);
return () => ipcRenderer.removeListener("css-injected", listener);
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
});

View File

@@ -28,6 +28,7 @@ import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss } from "./helpers";
import "./app.scss";
export interface AppProps {
@@ -233,6 +234,17 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
@@ -249,6 +261,14 @@ export function App() {
};
}, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View File

@@ -10,6 +10,7 @@
cursor: pointer;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&__image {
height: 100%;

View File

@@ -1,4 +1,5 @@
import { PersonIcon } from "@primer/octicons-react";
import cn from "classnames";
import "./avatar.scss";
@@ -14,11 +15,18 @@ export interface AvatarProps
src?: string | null;
}
export function Avatar({ size, alt, src, ...props }: AvatarProps) {
export function Avatar({ size, alt, src, className, ...props }: AvatarProps) {
return (
<div className="profile-avatar" style={{ width: size, height: size }}>
{src ? (
<img className="profile-avatar__image" alt={alt} src={src} {...props} />
<img
className={cn("profile-avatar__image", className)}
alt={alt}
src={src}
width={size}
height={size}
{...props}
/>
) : (
<PersonIcon size={size * 0.7} />
)}

View File

@@ -6,7 +6,10 @@ export interface BackdropProps {
children: React.ReactNode;
}
export function Backdrop({ isClosing = false, children }: BackdropProps) {
export function Backdrop({
isClosing = false,
children,
}: Readonly<BackdropProps>) {
return (
<div
className={cn("backdrop", {

View File

@@ -15,7 +15,7 @@ export function Button({
theme = "primary",
className,
...props
}: ButtonProps) {
}: Readonly<ButtonProps>) {
return (
<button
type="button"

View File

@@ -7,7 +7,7 @@ export interface CheckboxFieldProps
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string;
label: string | React.ReactNode;
}
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {

View File

@@ -0,0 +1,50 @@
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
interface SidebarGameItemProps {
game: LibraryGame;
handleSidebarGameClick: (event: React.MouseEvent, game: LibraryGame) => void;
getGameTitle: (game: LibraryGame) => string;
}
export function SidebarGameItem({
game,
handleSidebarGameClick,
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
return (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
);
}

View File

@@ -23,6 +23,7 @@
&__content {
display: flex;
flex-direction: column;
flex: 1;
padding: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2);
width: 100%;
@@ -54,6 +55,7 @@
display: flex;
color: globals.$muted-color;
border-radius: 4px;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
@@ -104,13 +106,14 @@
&__container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__section {
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: globals.$spacing-unit;
}

View File

@@ -18,11 +18,11 @@ import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -167,6 +167,10 @@ export function Sidebar() {
}
};
const favoriteGames = useMemo(() => {
return sortedLibrary.filter((game) => game.favorite);
}, [sortedLibrary]);
return (
<aside
ref={sidebarRef}
@@ -206,6 +210,23 @@ export function Sidebar() {
</ul>
</section>
{favoriteGames.length > 0 && (
<section className="sidebar__section">
<small className="sidebar__section-title">{t("favorites")}</small>
<ul className="sidebar__menu">
{favoriteGames.map((game) => (
<SidebarGameItem
key={game.id}
game={game}
handleSidebarGameClick={handleSidebarGameClick}
getGameTitle={getGameTitle}
/>
))}
</ul>
</section>
)}
<section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small>
@@ -217,39 +238,16 @@ export function Sidebar() {
/>
<ul className="sidebar__menu">
{filteredLibrary.map((game) => (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname ===
`/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted":
game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
))}
{filteredLibrary
.filter((game) => !game.favorite)
.map((game) => (
<SidebarGameItem
key={game.id}
game={game}
handleSidebarGameClick={handleSidebarGameClick}
getGameTitle={getGameTitle}
/>
))}
</ul>
</section>
</div>

View File

@@ -7,8 +7,9 @@
background-color: globals.$dark-background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
right: 16px;
bottom: 26px + globals.$spacing-unit;
right: calc(globals.$spacing-unit * 2);
// 28px is the height of the bottom panel
bottom: calc(28px + globals.$spacing-unit * 2);
overflow: hidden;
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Spectre";
export const VERSION_CODENAME = "Polychrome";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export const THEME_WEB_STORE_URL = "https://hydrathemes.shop";

View File

@@ -9,20 +9,32 @@ export interface SettingsContext {
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void;
clearTheme: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>;
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
}
export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {},
clearTheme: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
blockedUsers: [],
fetchBlockedUsers: async () => {},
appearance: {
theme: null,
authorId: null,
authorName: null,
},
});
const { Provider } = settingsContext;
@@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
export function SettingsContextProvider({
children,
}: SettingsContextProviderProps) {
}: Readonly<SettingsContextProviderProps>) {
const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [appearance, setAppearance] = useState<{
theme: string | null;
authorId: string | null;
authorName: string | null;
}>({
theme: null,
authorId: null,
authorName: null,
});
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2);
@@ -54,6 +77,36 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
useEffect(() => {
if (appearance.theme) setCurrentCategoryIndex(3);
}, [appearance.theme]);
useEffect(() => {
if (
defaultAppearanceTheme &&
defaultAppearanceAuthorId &&
defaultAppearanceAuthorName
) {
setAppearance({
theme: defaultAppearanceTheme,
authorId: defaultAppearanceAuthorId,
authorName: defaultAppearanceAuthorName,
});
}
}, [
defaultAppearanceTheme,
defaultAppearanceAuthorId,
defaultAppearanceAuthorName,
]);
const clearTheme = useCallback(() => {
setAppearance({
theme: null,
authorId: null,
authorName: null,
});
}, []);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks);
@@ -79,9 +132,11 @@ export function SettingsContextProvider({
setCurrentCategoryIndex,
clearSourceUrl,
fetchBlockedUsers,
clearTheme,
currentCategoryIndex,
sourceUrl,
blockedUsers,
appearance,
}}
>
{children}

View File

@@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { UserProfile, UserStats } from "@types";
import type { Badge, UserProfile, UserStats } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -16,6 +16,7 @@ export interface UserProfileContext {
getUserProfile: () => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -28,6 +29,7 @@ export const userProfileContext = createContext<UserProfileContext>({
getUserProfile: async () => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
});
const { Provider } = userProfileContext;
@@ -41,12 +43,13 @@ export interface UserProfileContextProviderProps {
export function UserProfileContextProvider({
children,
userId,
}: UserProfileContextProviderProps) {
}: Readonly<UserProfileContextProviderProps>) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [badges, setBadges] = useState<Badge[]>([]);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
);
@@ -101,12 +104,18 @@ export function UserProfileContextProvider({
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
setBadges(badges);
}, []);
useEffect(() => {
setUserProfile(null);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
}, [getUserProfile]);
getBadges();
}, [getUserProfile, getBadges]);
return (
<Provider
@@ -118,6 +127,7 @@ export function UserProfileContextProvider({
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
}}
>
{children}

View File

@@ -29,6 +29,9 @@ import type {
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
Badge,
Auth,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -85,6 +88,11 @@ declare global {
getDevelopers: () => Promise<string[]>;
/* Library */
toggleAutomaticCloudSync: (
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) => Promise<void>;
addGameToLibrary: (
shop: GameShop,
objectId: string,
@@ -96,6 +104,11 @@ declare global {
objectId: string,
executablePath: string | null
) => Promise<void>;
addGameToFavorites: (shop: GameShop, objectId: string) => Promise<void>;
removeGameFromFavorites: (
shop: GameShop,
objectId: string
) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -211,6 +224,7 @@ declare global {
) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>;
getBadges: () => Promise<Badge[]>;
platform: NodeJS.Platform;
/* Auto update */
@@ -221,6 +235,7 @@ declare global {
restartAndInstallUpdate: () => Promise<void>;
/* Auth */
getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
@@ -274,6 +289,23 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;
onCssInjected: (
cb: (cssString: string) => void
) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
}
interface Window {

View File

@@ -26,7 +26,7 @@ export const toastSlice = createSlice({
state.title = action.payload.title;
state.message = action.payload.message;
state.type = action.payload.type;
state.duration = action.payload.duration ?? 5000;
state.duration = action.payload.duration ?? 2000;
state.visible = true;
},
closeToast: (state) => {

View File

@@ -1,6 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
import { THEME_WEB_STORE_URL } from "./constants";
export const formatDownloadProgress = (
progress?: number,
@@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const injectCustomCss = (css: string) => {
try {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
if (css.startsWith(THEME_WEB_STORE_URL)) {
const link = document.createElement("link");
link.id = "custom-css";
link.rel = "stylesheet";
link.href = css;
document.head.appendChild(link);
} else {
const style = document.createElement("style");
style.id = "custom-css";
style.textContent = `
${css}
`;
document.head.appendChild(style);
}
} catch (error) {
console.error("failed to inject custom css:", error);
}
};
export const removeCustomCss = () => {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
};

View File

@@ -39,7 +39,7 @@ export function useDownload() {
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
dispatch(clearDownload());
if (lastPacket?.gameId === `${shop}:${objectId}`) dispatch(clearDownload());
};
const resumeDownload = async (shop: GameShop, objectId: string) => {

View File

@@ -1,18 +1,26 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
enum Feature {
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
Torbox = "TORBOX",
}
export function useFeature() {
const [features, setFeatures] = useState<string[] | null>(null);
useEffect(() => {
window.electron.getFeatures().then((features) => {
localStorage.setItem("features", JSON.stringify(features || []));
setFeatures(features || []);
});
}, []);
const isFeatureEnabled = (feature: Feature) => {
const features = JSON.parse(localStorage.getItem("features") || "[]");
if (!features) {
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
return features.includes(feature);
}
return features.includes(feature);
};

View File

@@ -11,6 +11,7 @@ import "@fontsource/noto-sans/500.css";
import "@fontsource/noto-sans/700.css";
import "react-loading-skeleton/dist/skeleton.css";
import "react-tooltip/dist/react-tooltip.css";
import { App } from "./app";
@@ -18,23 +19,17 @@ import { store } from "./store";
import resources from "@locales";
import { SuspenseWrapper } from "./components";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
const Home = React.lazy(() => import("./pages/home/home"));
const GameDetails = React.lazy(
() => import("./pages/game-details/game-details")
);
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile"));
const Achievements = React.lazy(
() => import("./pages/achievements/achievements")
);
import * as Sentry from "@sentry/react";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads";
import GameDetails from "./pages/game-details/game-details";
import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
@@ -79,32 +74,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route
path="/catalogue"
element={<SuspenseWrapper Component={Catalogue} />}
/>
<Route
path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route
path="/achievements"
element={<SuspenseWrapper Component={Achievements} />}
/>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
</Routes>
</HashRouter>
</Provider>

View File

@@ -31,7 +31,7 @@ $logo-max-width: 200px;
display: flex;
justify-content: center;
width: 100%;
gap: globals.$spacing-unit / 2;
gap: calc(globals.$spacing-unit / 2);
color: globals.$body-color;
cursor: pointer;

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