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 name: Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request on: pull_request
jobs: jobs:

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,10 @@ const copyAria2Macos = async () => {
}; };
const copyAria2 = () => { 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..."); console.log("Aria2 already exists, skipping download...");
return; return;
} }

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Game has no executable selected", "game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in", "sign_in": "Sign in",
"friends": "Friends", "friends": "Friends",
"need_help": "Need help?" "need_help": "Need help?",
"favorites": "Favorites"
}, },
"header": { "header": {
"search": "Search games", "search": "Search games",
@@ -177,6 +178,8 @@
"manage_files_description": "Manage which files will be backed up and restored", "manage_files_description": "Manage which files will be backed up and restored",
"select_folder": "Select folder", "select_folder": "Select folder",
"backup_from": "Backup from {{date}}", "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", "custom_backup_location_set": "Custom backup location set",
"no_directory_selected": "No directory selected", "no_directory_selected": "No directory selected",
"no_write_permission": "Cannot download into this directory. Click here to learn more.", "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_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_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_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": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@@ -307,10 +312,35 @@
"subscription_renew_cancelled": "Automatic renewal is disabled", "subscription_renew_cancelled": "Automatic renewal is disabled",
"subscription_renews_on": "Your subscription renews on {{date}}", "subscription_renews_on": "Your subscription renews on {{date}}",
"bill_sent_until": "Your next bill will be sent until this day", "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", "enable_torbox": "Enable Torbox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.", "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked", "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": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado", "game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión", "sign_in": "Iniciar sesión",
"friends": "Amigos", "friends": "Amigos",
"need_help": "¿Necesitas ayuda?" "need_help": "¿Necesitas ayuda?",
"favorites": "Favoritos"
}, },
"header": { "header": {
"search": "Buscar juegos", "search": "Buscar juegos",
@@ -173,6 +174,8 @@
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados", "manage_files_description": "Gestiona los archivos que serán respaldados y restaurados",
"select_folder": "Seleccionar carpeta", "select_folder": "Seleccionar carpeta",
"backup_from": "Copia de seguridad de {{date}}", "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", "custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
"clear": "Limpiar", "clear": "Limpiar",
"no_directory_selected": "No se seleccionó un directorio", "no_directory_selected": "No se seleccionó un directorio",
@@ -184,7 +187,13 @@
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}", "reset_achievements_description": "Esto reiniciará todos los logros de {{game}}",
"reset_achievements_title": "¿Estás seguro?", "reset_achievements_title": "¿Estás seguro?",
"reset_achievements_success": "Logros reiniciados exitosamente", "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": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",
@@ -302,7 +311,37 @@
"subscription_renew_cancelled": "Está desactivada la renovación automática", "subscription_renew_cancelled": "Está desactivada la renovación automática",
"subscription_renews_on": "Tú suscripción se renueva el {{date}}", "subscription_renews_on": "Tú suscripción se renueva el {{date}}",
"update_email": "Actualizar correo", "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": { "notifications": {
"download_complete": "Descarga completada", "download_complete": "Descarga completada",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,12 @@
"game_has_no_executable": "Jogo não possui executável selecionado", "game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login", "sign_in": "Login",
"friends": "Amigos", "friends": "Amigos",
"need_help": "Precisa de ajuda?" "need_help": "Precisa de ajuda?",
"favorites": "Favoritos"
}, },
"header": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Resultados da busca", "search_results": "Resultados da busca",
@@ -163,6 +165,8 @@
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo", "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", "achievements_not_sync": "Veja como exibir suas conquistas no perfil",
"backup_from": "Backup de {{date}}", "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", "custom_backup_location_set": "Localização customizada selecionada",
"select_folder": "Selecione a pasta", "select_folder": "Selecione a pasta",
"manage_files_description": "Gerencie quais arquivos serão feitos backup", "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_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_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_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": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -296,10 +302,33 @@
"subscription_renew_cancelled": "A renovação automática está desativada", "subscription_renew_cancelled": "A renovação automática está desativada",
"subscription_renews_on": "Sua assinatura renova dia {{date}}", "subscription_renews_on": "Sua assinatura renova dia {{date}}",
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia", "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", "enable_torbox": "Habilitar Torbox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.", "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", "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": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",

View File

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

View File

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

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Файл запуска игры не выбран", "game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти", "sign_in": "Войти",
"friends": "Друзья", "friends": "Друзья",
"need_help": "Нужна помощь?" "need_help": "Нужна помощь?",
"favorites": "Избранное"
}, },
"header": { "header": {
"search": "Поиск", "search": "Поиск",
@@ -177,12 +178,20 @@
"manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться", "manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться",
"select_folder": "Выбрать папку", "select_folder": "Выбрать папку",
"backup_from": "Резервная копия от {{date}}", "backup_from": "Резервная копия от {{date}}",
"automatic_backup_from": "Автоматическая резервная копия от {{date}}",
"enable_automatic_cloud_sync": "Включить автоматическую синхронизацию в облаке",
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии", "custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
"no_directory_selected": "Не выбран каталог", "no_directory_selected": "Не выбран каталог",
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.", "no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
"reset_achievements_title": "Вы уверены?", "reset_achievements_title": "Вы уверены?",
"reset_achievements_success": "Достижения успешно сброшены", "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": { "activation": {
"title": "Активировать Hydra", "title": "Активировать Hydra",
@@ -300,7 +309,36 @@
"become_subscriber": "Станьте обладателем Hydra Cloud", "become_subscriber": "Станьте обладателем Hydra Cloud",
"subscription_renew_cancelled": "Автоматическое продление отключено", "subscription_renew_cancelled": "Автоматическое продление отключено",
"subscription_renews_on": "Ваша подписка продлевается на {{date}}", "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": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import type { Auth } from "@types"; import type { Auth } from "@types";
import { Crypto } from "@main/services";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, { const auth = await db.get<string, Auth>(levelKeys.auth, {
@@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
}); });
if (!auth) return null; if (!auth) return null;
const payload = jwt.decode( const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
Crypto.decrypt(auth.accessToken)
) as jwt.JwtPayload;
if (!payload) return null; 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 { 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 type { GameShop } from "@types";
import axios from "axios"; import { t } from "i18next";
import os from "node:os"; import { format } from "date-fns";
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;
};
const uploadSaveGame = async ( const uploadSaveGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -46,61 +10,15 @@ const uploadSaveGame = async (
shop: GameShop, shop: GameShop,
downloadOptionTitle: string | null downloadOptionTitle: string | null
) => { ) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); return CloudSync.uploadSaveGame(
const bundleLocation = await bundleBackup(
shop,
objectId, 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); registerEvent("uploadSaveGame", uploadSaveGame);

View File

@@ -13,6 +13,8 @@ import "./catalogue/get-developers";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission"; import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library"; 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/create-game-shortcut";
import "./library/close-game"; import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
@@ -29,11 +31,13 @@ import "./library/remove-game";
import "./library/remove-game-from-library"; import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix"; import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements"; import "./library/reset-game-achievements";
import "./library/toggle-automatic-cloud-sync";
import "./misc/open-checkout"; import "./misc/open-checkout";
import "./misc/open-external"; import "./misc/open-external";
import "./misc/show-open-dialog"; import "./misc/show-open-dialog";
import "./misc/get-features"; import "./misc/get-features";
import "./misc/show-item-in-folder"; import "./misc/show-item-in-folder";
import "./misc/get-badges";
import "./torrenting/cancel-game-download"; import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download"; import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download"; import "./torrenting/resume-game-download";
@@ -56,6 +60,7 @@ import "./user/get-blocked-users";
import "./user/block-user"; import "./user/block-user";
import "./user/unblock-user"; import "./user/unblock-user";
import "./user/get-user-friends"; import "./user/get-user-friends";
import "./user/get-auth";
import "./user/get-user-stats"; import "./user/get-user-stats";
import "./user/report-user"; import "./user/report-user";
import "./user/get-unlocked-achievements"; import "./user/get-unlocked-achievements";
@@ -75,6 +80,16 @@ import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact"; import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path"; import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification"; 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"; import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); 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 { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared"; 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"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async ( const addGameToLibrary = async (
@@ -46,9 +46,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(levelKeys.game(shop, objectId), game); 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 { shell } from "electron";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { Crypto, HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import type { Auth } from "@types"; import type { Auth } from "@types";
@@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
} }
const paymentToken = await HydraApi.post("/auth/payment", { const paymentToken = await HydraApi.post("/auth/payment", {
refreshToken: Crypto.decrypt(auth.refreshToken), refreshToken: auth.refreshToken,
}).then((response) => response.accessToken); }).then((response) => response.accessToken);
const params = new URLSearchParams({ 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 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); registerEvent("cancelGameDownload", cancelGameDownload);

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import i18next from "i18next"; import i18next from "i18next";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services";
import { patchUserProfile } from "../profile/update-profile"; import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async ( const updateUserPreferences = async (
@@ -24,16 +23,6 @@ const updateUserPreferences = async (
patchUserProfile({ language: preferences.language }).catch(() => {}); 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) { if (!preferences.downloadsPath) {
preferences.downloadsPath = null; 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 return achievementsData
.map((achievementData) => { .map((achievementData) => {
const unlockedAchiementData = unlockedAchievements.find( const unlockedAchievementData = unlockedAchievements.find(
(localAchievement) => { (localAchievement) => {
return ( return (
localAchievement.name.toUpperCase() == localAchievement.name.toUpperCase() ==
@@ -45,11 +45,11 @@ export const getUnlockedAchievements = async (
? achievementData.icon ? achievementData.icon
: achievementData.icongray; : achievementData.icongray;
if (unlockedAchiementData) { if (unlockedAchievementData) {
return { return {
...achievementData, ...achievementData,
unlocked: true, unlocked: true,
unlockTime: unlockedAchiementData.unlockTime, unlockTime: unlockedAchievementData.unlockTime,
}; };
} }

View File

@@ -3,6 +3,7 @@ import updater from "electron-updater";
import i18n from "i18next"; import i18n from "i18next";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import kill from "kill-port";
import { electronApp, optimizer } from "@electron-toolkit/utils"; import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services"; import { logger, WindowManager } from "@main/services";
import resources from "@locales"; import resources from "@locales";
@@ -58,7 +59,7 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); 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, { const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8", valueEncoding: "utf-8",
@@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => {
if (url.host === "install-source") { if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`); 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) { } catch (error) {
logger.error("Error handling deep link", uri, 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-shop-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";
export * from "./themes";

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements"; import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types"; import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchivements = async (game: Game) => { export const updateLocalUnlockedAchievements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory = 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 { WindowManager } from "../window-manager";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; 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 { PythonRPC } from "../python-rpc";
import { import {
LibtorrentPayload, LibtorrentPayload,
@@ -219,8 +225,10 @@ export class DownloadManager {
} as PauseDownloadPayload) } as PauseDownloadPayload)
.catch(() => {}); .catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1); if (downloadKey === this.downloadingGameId) {
this.downloadingGameId = null; WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null;
}
} }
static async resumeDownload(download: Download) { static async resumeDownload(download: Download) {
@@ -228,14 +236,17 @@ export class DownloadManager {
} }
static async cancelDownload(downloadKey = this.downloadingGameId) { static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc
action: "cancel", .post("/action", {
game_id: downloadKey, action: "cancel",
}); game_id: downloadKey,
})
WindowManager.mainWindow?.setProgressBar(-1); .catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null); WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null; this.downloadingGameId = null;
} }
@@ -278,11 +289,12 @@ export class DownloadManager {
} }
case Downloader.PixelDrain: { case Downloader.PixelDrain: {
const id = download.uri.split("/").pop(); const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: downloadId,
url: `https://cdn.pd5-gamedriveorg.workers.dev/api/file/${id}`, url: downloadUrl,
save_path: download.downloadPath, save_path: download.downloadPath,
}; };
} }

View File

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

View File

@@ -1,47 +1,71 @@
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
export class DatanodesApi { 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> { public static async getDownloadUrl(downloadUrl: string): Promise<string> {
const parsedUrl = new URL(downloadUrl); try {
const pathSegments = parsedUrl.pathname.split("/"); const parsedUrl = new URL(downloadUrl);
const pathSegments = parsedUrl.pathname.split("/").filter(Boolean);
const fileCode = pathSegments[0];
const fileCode = decodeURIComponent(pathSegments[1]); await this.jar.setCookie("lang=english;", "https://datanodes.to");
const fileName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
const payload = new URLSearchParams({ const payload = new URLSearchParams({
op: "download2", op: "download2",
id: fileCode, id: fileCode,
rand: "", method_free: "Free Download >>",
referer: "https://datanodes.to/download", dl: "1",
method_free: "Free Download >>", });
method_premium: "",
adblock_detected: "",
});
const response: AxiosResponse = await this.session.post( const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download", "https://datanodes.to/download",
payload, payload,
{ {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "User-Agent":
Cookie: `lang=english; file_name=${fileName}; file_code=${fileCode};`, "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
Host: "datanodes.to", Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to", Origin: "https://datanodes.to",
Referer: "https://datanodes.to/download", "Content-Type": "application/x-www-form-urlencoded",
"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,
maxRedirects: 0, }
validateStatus: (status: number) => status === 302 || status < 400, );
if (response.status === 302) {
return response.headers["location"];
} }
);
if (response.status === 302) { if (typeof response.data === "object" && response.data.url) {
return response.headers["location"]; 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 "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; 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 { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels"; import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types"; import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions { interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@@ -32,8 +31,9 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true; private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly secondsToMilliseconds = (seconds: number) => private static secondsToMilliseconds(seconds: number) {
seconds * 1000; return seconds * 1000;
}
private static userAuth: HydraApiUserAuth = { private static userAuth: HydraApiUserAuth = {
authToken: "", authToken: "",
@@ -81,8 +81,8 @@ export class HydraApi {
db.put<string, Auth>( db.put<string, Auth>(
levelKeys.auth, levelKeys.auth,
{ {
accessToken: Crypto.encrypt(accessToken), accessToken,
refreshToken: Crypto.encrypt(refreshToken), refreshToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
}, },
{ valueEncoding: "json" } { valueEncoding: "json" }
@@ -204,12 +204,8 @@ export class HydraApi {
const user = result.at(1) as User | undefined; const user = result.at(1) as User | undefined;
this.userAuth = { this.userAuth = {
authToken: userAuth?.accessToken authToken: userAuth?.accessToken ?? "",
? Crypto.decrypt(userAuth.accessToken) refreshToken: userAuth?.refreshToken ?? "",
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: user?.subscription subscription: user?.subscription
? { expiresAt: user.subscription?.expiresAt } ? { expiresAt: user.subscription?.expiresAt }
@@ -258,7 +254,7 @@ export class HydraApi {
levelKeys.auth, levelKeys.auth,
{ {
...auth, ...auth,
accessToken: Crypto.encrypt(accessToken), accessToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
}, },
{ valueEncoding: "json" } { valueEncoding: "json" }

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ import axios from "axios";
import { exec } from "child_process"; import { exec } from "child_process";
import { ProcessPayload } from "./download/types"; import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level"; import { gamesSublevel, levelKeys } from "@main/level";
import { t } from "i18next";
import { CloudSync } from "./cloud-sync";
import { format } from "date-fns";
const commands = { 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 ""}'`, 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) { if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()).catch(() => {}); 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 { } else {
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {}); createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
} }
@@ -287,6 +302,18 @@ const onCloseGame = (game: Game) => {
performance.now() - gamePlaytime.lastSyncTick, performance.now() - gamePlaytime.lastSyncTick,
game.lastTimePlayed! game.lastTimePlayed!
).catch(() => {}); ).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 { } else {
createGame(game).catch(() => {}); createGame(game).catch(() => {});
} }

View File

@@ -17,13 +17,37 @@ import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level"; import { db, gamesSublevel, levelKeys } from "@main/level";
import { slice, sortBy } from "lodash-es"; import { slice, sortBy } from "lodash-es";
import type { UserPreferences } from "@types"; import type { ScreenState, UserPreferences } from "@types";
import { AuthPage } from "@shared"; import { AuthPage } from "@shared";
import { isStaging } from "@main/constants"; import { isStaging } from "@main/constants";
export class WindowManager { export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null; 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 = "") { private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli. // HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production. // 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; if (this.mainWindow) return;
this.mainWindow = new BrowserWindow({ const { isMaximized = false, ...configWithoutMaximized } =
width: 1200, await this.loadScreenConfig();
height: 720,
minWidth: 1024, this.updateInitialConfig(configWithoutMaximized);
minHeight: 540,
backgroundColor: "#1c1c1c", this.mainWindow = new BrowserWindow(
titleBarStyle: process.platform === "linux" ? "default" : "hidden", this.initialConfigInitializationMainWindow
icon, );
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: { if (isMaximized) {
symbolColor: "#DADBE1", this.mainWindow.maximize();
color: "#151515", }
height: 34,
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders( this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => { (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) { if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit(); 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) { public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow(); if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash); this.loadMainWindowURL(hash);

View File

@@ -14,6 +14,7 @@ import type {
CatalogueSearchPayload, CatalogueSearchPayload,
SeedingStatus, SeedingStatus,
GameAchievement, GameAchievement,
Theme,
} from "@types"; } from "@types";
import type { AuthPage, CatalogueCategory } from "@shared"; import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -100,6 +101,17 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("putDownloadSource", objectIds), ipcRenderer.invoke("putDownloadSource", objectIds),
/* Library */ /* Library */
toggleAutomaticCloudSync: (
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) =>
ipcRenderer.invoke(
"toggleAutomaticCloudSync",
shop,
objectId,
automaticCloudSync
),
addGameToLibrary: (shop: GameShop, objectId: string, title: string) => addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
createGameShortcut: (shop: GameShop, objectId: string) => createGameShortcut: (shop: GameShop, objectId: string) =>
@@ -110,11 +122,16 @@ contextBridge.exposeInMainWorld("electron", {
executablePath: string | null executablePath: string | null
) => ) =>
ipcRenderer.invoke("updateExecutablePath", shop, objectId, executablePath), 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: ( updateLaunchOptions: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
launchOptions: string | null launchOptions: string | null
) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions), ) => ipcRenderer.invoke("updateLaunchOptions", shop, objectId, launchOptions),
selectGameWinePrefix: ( selectGameWinePrefix: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -260,6 +277,7 @@ contextBridge.exposeInMainWorld("electron", {
showItemInFolder: (path: string) => showItemInFolder: (path: string) =>
ipcRenderer.invoke("showItemInFolder", path), ipcRenderer.invoke("showItemInFolder", path),
getFeatures: () => ipcRenderer.invoke("getFeatures"), getFeatures: () => ipcRenderer.invoke("getFeatures"),
getBadges: () => ipcRenderer.invoke("getBadges"),
platform: process.platform, platform: process.platform,
/* Auto update */ /* Auto update */
@@ -319,6 +337,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop), ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */ /* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"),
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) => openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page), ipcRenderer.invoke("openAuthWindow", page),
@@ -342,4 +361,30 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), 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 { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss } from "./helpers";
import "./app.scss"; import "./app.scss";
export interface AppProps { export interface AppProps {
@@ -233,6 +234,17 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]); }, [updateRepacks]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
const playAudio = useCallback(() => { const playAudio = useCallback(() => {
const audio = new Audio(achievementSound); const audio = new Audio(achievementSound);
audio.volume = 0.2; audio.volume = 0.2;
@@ -249,6 +261,14 @@ export function App() {
}; };
}, [playAudio]); }, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
const handleToastClose = useCallback(() => { const handleToastClose = useCallback(() => {
dispatch(closeToast()); dispatch(closeToast());
}, [dispatch]); }, [dispatch]);

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ export interface CheckboxFieldProps
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> { > {
label: string; label: string | React.ReactNode;
} }
export function CheckboxField({ label, ...props }: CheckboxFieldProps) { 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 { &__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2); gap: calc(globals.$spacing-unit * 2);
width: 100%; width: 100%;
@@ -54,6 +55,7 @@
display: flex; display: flex;
color: globals.$muted-color; color: globals.$muted-color;
border-radius: 4px; border-radius: 4px;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
} }
@@ -104,13 +106,14 @@
&__container { &__container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
overflow: hidden; overflow: hidden;
} }
&__section { &__section {
gap: calc(globals.$spacing-unit * 2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: globals.$spacing-unit; padding-bottom: globals.$spacing-unit;
} }

View File

@@ -18,11 +18,11 @@ import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile"; import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import cn from "classnames"; import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react"; import { CommentDiscussionIcon } from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_INITIAL_WIDTH = 250;
@@ -167,6 +167,10 @@ export function Sidebar() {
} }
}; };
const favoriteGames = useMemo(() => {
return sortedLibrary.filter((game) => game.favorite);
}, [sortedLibrary]);
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
@@ -206,6 +210,23 @@ export function Sidebar() {
</ul> </ul>
</section> </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"> <section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small> <small className="sidebar__section-title">{t("my_library")}</small>
@@ -217,39 +238,16 @@ export function Sidebar() {
/> />
<ul className="sidebar__menu"> <ul className="sidebar__menu">
{filteredLibrary.map((game) => ( {filteredLibrary
<li .filter((game) => !game.favorite)
key={game.id} .map((game) => (
className={cn("sidebar__menu-item", { <SidebarGameItem
"sidebar__menu-item--active": key={game.id}
location.pathname === game={game}
`/game/${game.shop}/${game.objectId}`, handleSidebarGameClick={handleSidebarGameClick}
"sidebar__menu-item--muted": getGameTitle={getGameTitle}
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>
))}
</ul> </ul>
</section> </section>
</div> </div>

View File

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

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared"; import { Downloader } from "@shared";
export const VERSION_CODENAME = "Spectre"; export const VERSION_CODENAME = "Polychrome";
export const DOWNLOADER_NAME = { export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid", [Downloader.RealDebrid]: "Real-Debrid",
@@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
}; };
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; 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>; updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>; setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void; clearSourceUrl: () => void;
clearTheme: () => void;
sourceUrl: string | null; sourceUrl: string | null;
currentCategoryIndex: number; currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"]; blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>; fetchBlockedUsers: () => Promise<void>;
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
} }
export const settingsContext = createContext<SettingsContext>({ export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {}, updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {}, setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {}, clearSourceUrl: () => {},
clearTheme: () => {},
sourceUrl: null, sourceUrl: null,
currentCategoryIndex: 0, currentCategoryIndex: 0,
blockedUsers: [], blockedUsers: [],
fetchBlockedUsers: async () => {}, fetchBlockedUsers: async () => {},
appearance: {
theme: null,
authorId: null,
authorName: null,
},
}); });
const { Provider } = settingsContext; const { Provider } = settingsContext;
@@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
export function SettingsContextProvider({ export function SettingsContextProvider({
children, children,
}: SettingsContextProviderProps) { }: Readonly<SettingsContextProviderProps>) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null); 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 [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]); const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls"); const defaultSourceUrl = searchParams.get("urls");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
useEffect(() => { useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2); if (sourceUrl) setCurrentCategoryIndex(2);
@@ -54,6 +77,36 @@ export function SettingsContextProvider({
} }
}, [defaultSourceUrl]); }, [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 fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0); const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks); setBlockedUsers(blockedUsers.blocks);
@@ -79,9 +132,11 @@ export function SettingsContextProvider({
setCurrentCategoryIndex, setCurrentCategoryIndex,
clearSourceUrl, clearSourceUrl,
fetchBlockedUsers, fetchBlockedUsers,
clearTheme,
currentCategoryIndex, currentCategoryIndex,
sourceUrl, sourceUrl,
blockedUsers, blockedUsers,
appearance,
}} }}
> >
{children} {children}

View File

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

View File

@@ -29,6 +29,9 @@ import type {
LibraryGame, LibraryGame,
GameRunning, GameRunning,
TorBoxUser, TorBoxUser,
Theme,
Badge,
Auth,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage"; import type disk from "diskusage";
@@ -85,6 +88,11 @@ declare global {
getDevelopers: () => Promise<string[]>; getDevelopers: () => Promise<string[]>;
/* Library */ /* Library */
toggleAutomaticCloudSync: (
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) => Promise<void>;
addGameToLibrary: ( addGameToLibrary: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -96,6 +104,11 @@ declare global {
objectId: string, objectId: string,
executablePath: string | null executablePath: string | null
) => Promise<void>; ) => Promise<void>;
addGameToFavorites: (shop: GameShop, objectId: string) => Promise<void>;
removeGameFromFavorites: (
shop: GameShop,
objectId: string
) => Promise<void>;
updateLaunchOptions: ( updateLaunchOptions: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -211,6 +224,7 @@ declare global {
) => Promise<Electron.OpenDialogReturnValue>; ) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>; showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>; getFeatures: () => Promise<string[]>;
getBadges: () => Promise<Badge[]>;
platform: NodeJS.Platform; platform: NodeJS.Platform;
/* Auto update */ /* Auto update */
@@ -221,6 +235,7 @@ declare global {
restartAndInstallUpdate: () => Promise<void>; restartAndInstallUpdate: () => Promise<void>;
/* Auth */ /* Auth */
getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>; openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>; getSessionHash: () => Promise<string | null>;
@@ -274,6 +289,23 @@ declare global {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>; 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 { interface Window {

View File

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

View File

@@ -1,6 +1,7 @@
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
import { THEME_WEB_STORE_URL } from "./constants";
export const formatDownloadProgress = ( export const formatDownloadProgress = (
progress?: number, progress?: number,
@@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) => export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString(); 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) => { const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId); await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary(); await updateLibrary();
dispatch(clearDownload()); if (lastPacket?.gameId === `${shop}:${objectId}`) dispatch(clearDownload());
}; };
const resumeDownload = async (shop: GameShop, objectId: string) => { const resumeDownload = async (shop: GameShop, objectId: string) => {

View File

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

View File

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

View File

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

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