Merge branch 'mobile-resumable' of https://github.com/ente-io/ente into mobile-resumable

This commit is contained in:
Prateek Sunal 2024-04-16 21:44:29 +05:30
commit 6efedfdd28
497 changed files with 9143 additions and 24645 deletions

View file

@ -85,7 +85,8 @@ jobs:
- name: Install dependencies for desktop build
run: |
sudo apt-get update -y
sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi7
sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
sudo updatedb --localpaths='/usr/lib/x86_64-linux-gnu'
- name: Install appimagetool
run: |

View file

@ -9,7 +9,7 @@ on:
- ".github/workflows/mobile-lint.yml"
env:
FLUTTER_VERSION: "3.13.4"
FLUTTER_VERSION: "3.19.5"
jobs:
lint:

View file

@ -9,7 +9,7 @@ on:
- "photos-v*"
env:
FLUTTER_VERSION: "3.13.4"
FLUTTER_VERSION: "3.19.5"
jobs:
build:

View file

@ -67,8 +67,6 @@ PODS:
- Toast
- local_auth_darwin (0.0.1):
- Flutter
- local_auth_ios (0.0.1):
- Flutter
- move_to_background (0.0.1):
- Flutter
- MTBBarcodeScanner (5.0.11)
@ -99,8 +97,6 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- smart_auth (0.0.1):
- Flutter
- sodium_libs (2.2.1):
- Flutter
- sqflite (0.0.3):
@ -142,7 +138,6 @@ DEPENDENCIES:
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@ -151,7 +146,6 @@ DEPENDENCIES:
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- smart_auth (from `.symlinks/plugins/smart_auth/ios`)
- sodium_libs (from `.symlinks/plugins/sodium_libs/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
@ -202,8 +196,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/fluttertoast/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
local_auth_ios:
:path: ".symlinks/plugins/local_auth_ios/ios"
move_to_background:
:path: ".symlinks/plugins/move_to_background/ios"
package_info_plus:
@ -220,8 +212,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
smart_auth:
:path: ".symlinks/plugins/smart_auth/ios"
sodium_libs:
:path: ".symlinks/plugins/sodium_libs/ios"
sqflite:
@ -245,11 +235,10 @@ SPEC CHECKSUMS:
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
@ -264,7 +253,6 @@ SPEC CHECKSUMS:
SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 73b7fc691fdc43277614250e04d183740cb15078

View file

@ -365,7 +365,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -439,7 +439,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -513,7 +513,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -587,7 +587,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -661,7 +661,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth;
INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View file

@ -78,14 +78,12 @@
"data": "البيانات",
"importCodes": "رمزالاستيراد",
"importTypePlainText": "نص عادي",
"importTypeEnteEncrypted": "تصدير مشفر ente",
"passwordForDecryptingExport": "كلمة المرور لفك تشفير التصدير",
"passwordEmptyError": "لا يمكن أن تكون كلمة المرور فارغة",
"importFromApp": "استيراد الرموز من {appName}",
"importGoogleAuthGuide": "قم بتصدير حساباتك من Google Authenticator إلى رمز QR code باستخدام خيار \"Transfer Accounts\" ثم استخدم جهازًا آخر لمسح رمز الاستجابة السريعة ضوئيًا.\n\nنصيحة: يمكنك استخدام كاميرا الويب الخاصة بالكمبيوتر المحمول لالتقاط صورة لرمز الاستجابة السريعة.",
"importSelectJsonFile": "حدد ملف JSON",
"importSelectAppExport": "حدد ملف التصدير {appName}",
"importEnteEncGuide": "حدد ملف JSON المشفر الذي تم تصديره من ente",
"importRaivoGuide": "استخدم خيار تصدير OTP إلى أرشيف Zip في إعدادات Raivo.\n\nاستخرج ملف zip واسترد ملف JSON.",
"importBitwardenGuide": "استخدم خيار \"تصدير خزانة\" داخل أدوات Bitwarden واستيراد ملف JSON غير مشفر.",
"importAegisGuide": "استخدم خيار \"Export the vault\" في إعدادات Aegis.\n\nإذا كان المخزن الخاص بك مشفرًا، فستحتاج إلى إدخال كلمة مرور المخزن لفك تشفير المخزن.",
@ -115,22 +113,18 @@
"copied": "تم النسخ",
"pleaseTryAgain": "حاول مرة اخرى",
"existingUser": "المستخدم موجود",
"newUser": "جديد إلى Ente",
"delete": "حذف",
"enterYourPasswordHint": "أدخل كلمة المرور الخاصة بك",
"forgotPassword": "هل نسيت كلمة المرور",
"oops": "عذرًا",
"suggestFeatures": "اقتراح ميزة",
"faq": "الأسئلة الأكثر شيوعاً",
"faq_q_1": "ما مدى أمان المصادقة؟",
"faq_a_1": "يتم تشفير جميع الرموز التي تقوم بنسخها احتياطا عبر Ente. وهذا يعني أنه يمكنك فقط الوصول إلى الرموز الخاصة بك. تطبيقاتنا مفتوحة المصدر وقد تم مراجعة التشفير خارجيا.",
"faq_q_2": "هل يمكنني الوصول إلى رموزي على سطح المكتب؟",
"faq_a_2": "يمكنك الوصول إلى رموزك على الويب @ auth.ente.io.",
"faq_q_3": "كيف يمكنني حذف الرموز؟",
"faq_a_3": "يمكنك حذف الرمز عن طريق السحب لليسار على هذا العنصر.",
"faq_q_4": "كيف يمكنني دعم هذا المشروع؟",
"faq_a_4": "يمكنك دعم تطوير هذا المشروع عن طريق الاشتراك في تطبيق الصور @ ente.io.",
"faq_q_5": "كيف يمكنني تمكين قفل FaceID في المصادقة Ente",
"faq_a_5": "يمكنك تمكين قفل FaceID تحت الإعدادات => الحماية => قفل الشاشة.",
"somethingWentWrongMessage": "حدث خطأ ما، يرجى المحاولة مرة أخرى",
"leaveFamily": "مغادرة خطة العائلة",
@ -144,6 +138,8 @@
"enterCodeHint": "أدخل الرمز المكون من 6 أرقام من\nتطبيق المصادقة",
"lostDeviceTitle": "جهاز مفقود ؟",
"twoFactorAuthTitle": "المصادقة الثنائية",
"passkeyAuthTitle": "التحقق من مفتاح المرور",
"verifyPasskey": "تحقق من مفتاح المرور",
"recoverAccount": "إسترجاع الحساب",
"enterRecoveryKeyHint": "أدخل رمز الاسترداد",
"recover": "استرداد",
@ -197,6 +193,8 @@
"recoveryKeySaveDescription": "نحن لا نخزن هذا المفتاح، يرجى حفظ مفتاح الـ 24 كلمة هذا في مكان آمن.",
"doThisLater": "قم بهذا لاحقاً",
"saveKey": "حفظ المفتاح",
"save": "حفظ",
"send": "إرسال",
"back": "الرجوع",
"createAccount": "إنشاء حساب",
"passwordStrength": "قوة كلمة المرور: {passwordStrengthValue}",
@ -344,7 +342,6 @@
"deleteCodeAuthMessage": "المصادقة لحذف الرمز",
"showQRAuthMessage": "المصادقة لإظهار رمز QR",
"confirmAccountDeleteTitle": "تأكيد حذف الحساب",
"confirmAccountDeleteMessage": "هذا الحساب مرتبط بتطبيقات Ente أخرى، إذا كنت تستخدم أي منها.\n\nبياناتك التي تم تحميلها، عبر جميع تطبيقات Ente سيتم جدولتها للحذف، وسيتم حذف حسابك بشكل دائم.",
"androidBiometricHint": "التحقق من الهوية",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -404,5 +401,7 @@
"signOutOtherDevices": "تسجيل الخروج من الأجهزة الأخرى",
"doNotSignOut": "لا تقم بتسجيل الخروج",
"hearUsWhereTitle": "كيف سمعت عن Ente؟ (اختياري)",
"hearUsExplanation": "نحن لا نتتبع تثبيت التطبيق. سيكون من المفيد إذا أخبرتنا أين وجدتنا!"
"hearUsExplanation": "نحن لا نتتبع تثبيت التطبيق. سيكون من المفيد إذا أخبرتنا أين وجدتنا!",
"passkey": "مفتاح المرور",
"developerSettings": "اعدادات المطور"
}

View file

@ -78,14 +78,12 @@
"data": "Datei",
"importCodes": "Codes importieren",
"importTypePlainText": "Klartext",
"importTypeEnteEncrypted": "ente verschlüsselt exportieren",
"passwordForDecryptingExport": "Passwort um den Export zu entschlüsseln",
"passwordEmptyError": "Passwort kann nicht leer sein",
"importFromApp": "Importiere Codes von {appName}",
"importGoogleAuthGuide": "Exportiere deine Accounts von Google Authenticator zu einem QR-Code, durch die \"Konten übertragen\" Option. Scanne den QR-Code danach mit einem anderen Gerät.\n\nTipp: Du kannst die Kamera eines Laptops verwenden, um ein Foto den dem QR-Code zu erstellen.",
"importSelectJsonFile": "Wähle eine JSON-Datei",
"importSelectAppExport": "{appName} Exportdatei auswählen",
"importEnteEncGuide": "Wähle die von ente exportierte, verschlüsselte JSON-Datei",
"importRaivoGuide": "Verwenden Sie die Option \"Export OTPs to Zip archive\" in den Raivo-Einstellungen.\n\nEntpacken Sie die Zip-Datei und importieren Sie die JSON-Datei.",
"importBitwardenGuide": "Verwenden Sie die Option \"Tresor exportieren\" innerhalb der Bitwarden Tools und importieren Sie die unverschlüsselte JSON-Datei.",
"importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Aegis-Einstellungen.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.",
@ -115,22 +113,18 @@
"copied": "Kopiert",
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
"existingUser": "Bestehender Benutzer",
"newUser": "Neu bei ente",
"delete": "Löschen",
"enterYourPasswordHint": "Geben Sie Ihr Passwort ein",
"forgotPassword": "Passwort vergessen",
"oops": "Hopla",
"suggestFeatures": "Features vorschlagen",
"faq": "FAQ",
"faq_q_1": "Wie sicher ist ente Auth?",
"faq_a_1": "Alle Codes, die Sie über ente sichern, werden Ende-zu-Ende-verschlüsselt gespeichert. Das bedeutet, dass nur Sie auf Ihre Codes zugreifen können. Unsere Apps sind Open Source und unsere Kryptografie wurde extern überprüft.",
"faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?",
"faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.",
"faq_q_3": "Wie kann ich Codes löschen?",
"faq_a_3": "Sie können einen Code löschen, indem Sie auf dem Code nach links wischen.",
"faq_q_4": "Wie kann ich das Projekt unterstützen?",
"faq_a_4": "Sie können die Entwicklung dieses Projekts unterstützen, indem Sie unsere Fotos-App auf ente.io abonnieren.",
"faq_q_5": "Wie kann ich FaceID Sperre in ente Auth aktivieren",
"faq_a_5": "Sie können FaceID unter Einstellungen → Sicherheit → Sperrbildschirm aktivieren.",
"somethingWentWrongMessage": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
"leaveFamily": "Familie verlassen",
@ -346,7 +340,6 @@
"deleteCodeAuthMessage": "Authentifizieren, um Code zu löschen",
"showQRAuthMessage": "Authentifizieren, um QR-Code anzuzeigen",
"confirmAccountDeleteTitle": "Kontolöschung bestätigen",
"confirmAccountDeleteMessage": "Dieses Konto ist mit anderen ente Apps verknüpft, sofern du diese benutzt.\n\nDeine hochgeladenen Daten werden zur permanenten Löschung freigegeben. Dies gilt für alle ente Apps.",
"androidBiometricHint": "Identität bestätigen",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,14 @@
"data": "Data",
"importCodes": "Import codes",
"importTypePlainText": "Plain text",
"importTypeEnteEncrypted": "ente Encrypted export",
"importTypeEnteEncrypted": "Ente Encrypted export",
"passwordForDecryptingExport": "Password to decrypt export",
"passwordEmptyError": "Password can not be empty",
"importFromApp": "Import codes from {appName}",
"importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.",
"importSelectJsonFile": "Select JSON file",
"importSelectAppExport": "Select {appName} export file",
"importEnteEncGuide": "Select the encrypted JSON file exported from ente",
"importEnteEncGuide": "Select the encrypted JSON file exported from Ente",
"importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.",
"importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.",
"importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.",
@ -115,22 +115,22 @@
"copied": "Copied",
"pleaseTryAgain": "Please try again",
"existingUser": "Existing User",
"newUser": "New to ente",
"newUser": "New to Ente",
"delete": "Delete",
"enterYourPasswordHint": "Enter your password",
"forgotPassword": "Forgot password",
"oops": "Oops",
"suggestFeatures": "Suggest features",
"faq": "FAQ",
"faq_q_1": "How secure is ente Auth?",
"faq_a_1": "All codes you backup via ente is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.",
"faq_q_1": "How secure is Auth?",
"faq_a_1": "All codes you backup via Auth is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.",
"faq_q_2": "Can I access my codes on desktop?",
"faq_a_2": "You can access your codes on the web @ auth.ente.io.",
"faq_q_3": "How can I delete codes?",
"faq_a_3": "You can delete a code by swiping left on that item.",
"faq_q_4": "How can I support this project?",
"faq_a_4": "You can support the development of this project by subscribing to our Photos app @ ente.io.",
"faq_q_5": "How can I enable FaceID lock in ente Auth",
"faq_q_5": "How can I enable FaceID lock in Auth",
"faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.",
"somethingWentWrongMessage": "Something went wrong, please try again",
"leaveFamily": "Leave family",
@ -350,7 +350,7 @@
"deleteCodeAuthMessage": "Authenticate to delete code",
"showQRAuthMessage": "Authenticate to show QR code",
"confirmAccountDeleteTitle": "Confirm account deletion",
"confirmAccountDeleteMessage": "This account is linked to other ente apps, if you use any.\n\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.",
"confirmAccountDeleteMessage": "This account is linked to other Ente apps, if you use any.\n\nYour uploaded data, across all Ente apps, will be scheduled for deletion, and your account will be permanently deleted.",
"androidBiometricHint": "Verify identity",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,12 @@
"data": "Datos",
"importCodes": "Importar códigos",
"importTypePlainText": "Texto sin formato",
"importTypeEnteEncrypted": "Exportación cifrada ente",
"passwordForDecryptingExport": "Contraseña para descifrar exportación",
"passwordEmptyError": "La contraseña no puede estar vacía",
"importFromApp": "Importar códigos de {appName}",
"importGoogleAuthGuide": "Exportar tus cuentas desde Google Authenticator a un código QR usando la opción \"Transferir Cuentas\". A continuación, usando otro dispositivo, escanee el código QR.\n\nConsejo: Puede usar la webcam de su portátil para tomar una foto del código QR.",
"importSelectJsonFile": "Seleccione el archivo JSON",
"importSelectAppExport": "Seleccione el archivo de exportación de {appName}",
"importEnteEncGuide": "Seleccione el archivo JSON cifrado exportado desde ente",
"importRaivoGuide": "Utilice la opción \"Exportar códigos a un archivo de Zip\" en la configuración de Raivo.\n\nExtraiga el archivo zip e importe el archivo JSON.",
"importBitwardenGuide": "Use la opción \"Exportar caja fuerte\" dentro del menú Herramientas de Bitwarden e importe el fichero JSON no crifrado.",
"importAegisGuide": "Utilice la opción \"Exportar la bóveda\" en ajustes de Aegis.\n\nSi tu bóveda es cifrada, necesitara entrar contraseña de bóveda para descifrar la bóveda.",
@ -115,22 +113,18 @@
"copied": "Copiado",
"pleaseTryAgain": "Por favor, inténtalo nuevamente",
"existingUser": "Usuario existente",
"newUser": "Nuevo en ente",
"delete": "Borrar",
"enterYourPasswordHint": "Ingrese su contraseña",
"forgotPassword": "Olvidé mi contraseña",
"oops": "Ups",
"suggestFeatures": "Sugerir funcionalidades",
"faq": "Preguntas Frecuentes",
"faq_q_1": "¿Cuán seguro es ente Auth?",
"faq_a_1": "Todos los códigos que copia de seguridad vía ente se almacenan cifrados de extremo a extremo. Esto significa que solo usted puede acceder a sus códigos. Nuestras aplicaciones son de código abierto y nuestra criptografía ha sido auditada externamente.",
"faq_q_2": "¿Puedo acceder a mis códigos en el escritorio?",
"faq_a_2": "Puede acceder a tus códigos en la web en auth.ente.io.",
"faq_q_3": "¿Cómo puedo borrar códigos?",
"faq_a_3": "Puede eliminar un código deslizando a la izquierda en ese elemento.",
"faq_q_4": "¿Cómo puedo apoyar este proyecto?",
"faq_a_4": "Puedes apoyar el desarrollo de este proyecto suscribiéndote a nuestra app de Fotos en ente.io.",
"faq_q_5": "¿Cómo puedo habilitar bloqueo FaceID en ente Auth",
"faq_a_5": "Puede activar el bloqueo FaceID en Ajustes → Seguridad → Pantalla de bloqueo.",
"somethingWentWrongMessage": "Algo ha ido mal, por favor, prueba otra vez",
"leaveFamily": "Dejar plan familiar",
@ -344,7 +338,6 @@
"deleteCodeAuthMessage": "Autenticar para borrar código",
"showQRAuthMessage": "Autenticar para mostrar código QR",
"confirmAccountDeleteTitle": "Confirmar eliminación de la cuenta",
"confirmAccountDeleteMessage": "Esta cuenta está vinculada a otras aplicaciones de ente, si utiliza alguna.\n\nSe programará la eliminación de los datos que cargue en todas las aplicaciones de ente y su cuenta se eliminará permanentemente.",
"androidBiometricHint": "Verificar identidad",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -85,7 +85,6 @@
"copied": "کپی شد",
"pleaseTryAgain": "لطفا دوباره تلاش کنید",
"existingUser": "کاربر موجود",
"newUser": "کاربر جدید ente",
"delete": "حذف",
"enterYourPasswordHint": "رمز عبور خود را وارد کنید",
"forgotPassword": "فراموشی رمز عبور",

View file

@ -74,7 +74,6 @@
"copied": "Jäljennetty",
"pleaseTryAgain": "Yritä uudestaan",
"existingUser": "Jo valmiiksi olemassaoleva käyttäjä",
"newUser": "Uusi Ente-käyttäjä",
"delete": "Poista",
"enterYourPasswordHint": "Syötä salasanasi",
"forgotPassword": "Olen unohtanut salasanani",

View file

@ -1,5 +1,6 @@
{
"account": "Compte",
"unlock": "Déverrouiller",
"recoveryKey": "Clé de récupération",
"counterAppBarTitle": "Compteur",
"@counterAppBarTitle": {
@ -77,15 +78,17 @@
"data": "Données",
"importCodes": "Importer les codes",
"importTypePlainText": "Texte brut",
"importTypeEnteEncrypted": "ente Exportation chiffrée",
"passwordForDecryptingExport": "Mot de passe pour déchiffrer l'exportation",
"passwordEmptyError": "Le mot de passe ne peut pas être vide",
"importFromApp": "Importer des codes depuis {appName}",
"importGoogleAuthGuide": "Exportez vos comptes depuis Google Authenticator vers un code QR en utilisant l'option \"Transférer des comptes\". Ensuite, en utilisant un autre appareil, scannez le code QR.\n\nAstuce : Vous pouvez utiliser la webcam de votre ordinateur portable pour prendre une photo du code QR.",
"importSelectJsonFile": "Sélectionnez un fichier JSON",
"importEnteEncGuide": "Sélectionnez le fichier JSON chiffré exporté à partir de ente",
"importSelectAppExport": "Sélectionnez le fichier d'exportation {appName}",
"importRaivoGuide": "Utilisez l'option \"Exporter les OTPs vers l'archive Zip\" dans les paramètres de Raivo.\n\nExtrayez le fichier zip et importez le fichier JSON.",
"importBitwardenGuide": "Utilisez l'option « Exporter le coffre » dans les outils Bitwarden et importez le fichier JSON non chiffré.",
"importAegisGuide": "Utilisez l'option \"Exporter le coffre-fort\" dans les paramètres d'Aegis.\n\nSi votre coffre-fort est crypté, vous devrez saisir le mot de passe du coffre-fort pour déchiffrer le coffre-fort.",
"import2FasGuide": "Utilisez l'option \"Paramètres->Sauvegarde -Export\" dans 2FAS.\n\nSi votre sauvegarde est chiffrée, vous devrez entrer le mot de passe pour déchiffrer la sauvegarde",
"importLastpassGuide": "Utilisez l'option \"Transférer des comptes\" dans les paramètres de l'authentificateur Lastpass et appuyez sur \"Exporter des comptes vers un fichier\". Importez le JSON téléchargé.",
"exportCodes": "Exporter les codes",
"importLabel": "Importer",
"importInstruction": "Veuillez sélectionner un fichier qui contient une liste de vos codes dans le format suivant",
@ -97,6 +100,8 @@
"authToViewYourRecoveryKey": "Veuillez vous authentifier pour afficher votre clé de récupération",
"authToChangeYourEmail": "Veuillez vous authentifier pour modifier votre adresse e-mail",
"authToChangeYourPassword": "Veuillez vous authentifier pour modifier votre mot de passe",
"authToViewSecrets": "Veuillez vous authentifier pour voir vos souvenirs",
"authToInitiateSignIn": "Veuillez vous authentifier pour ouvrir une session de sauvegarde.",
"ok": "Ok",
"cancel": "Annuler",
"yes": "Oui",
@ -108,22 +113,18 @@
"copied": "Copié",
"pleaseTryAgain": "Veuillez réessayer",
"existingUser": "Utilisateur existant",
"newUser": "Nouveau sur ente",
"delete": "Supprimer",
"enterYourPasswordHint": "Saisir votre mot de passe",
"forgotPassword": "Mot de passe oublié",
"oops": "Oups",
"suggestFeatures": "Suggérer des fonctionnalités",
"faq": "FAQ",
"faq_q_1": "À quel point ente Auth est-il sécurisé ?",
"faq_a_1": "Tous les codes que vous sauvegardez via ente sont chiffrés de bout en bout. Cela signifie que vous seul pouvez accéder à vos codes. Nos applications sont open source et notre cryptographie a fait l'objet d'un audit externe.",
"faq_q_2": "Puis-je accéder à mes codes sur mon ordinateur ?",
"faq_a_2": "Vous pouvez accéder à vos codes sur le web via auth.ente.io.",
"faq_q_3": "Comment puis-je supprimer des codes ?",
"faq_a_3": "Vous pouvez supprimer un code en glissant vers la gauche.",
"faq_q_4": "Comment puis-je soutenir le projet ?",
"faq_a_4": "Vous pouvez soutenir le développement de ce projet en vous abonnant à notre application Photos, ente.io.",
"faq_q_5": "Comment puis-je activer le verrouillage FaceID sur ente Auth",
"faq_a_5": "Vous pouvez activer le verrouillage FaceID dans Paramètres → Sécurité → Écran de verrouillage.",
"somethingWentWrongMessage": "Quelque chose s'est mal passé, veuillez recommencer",
"leaveFamily": "Quitter le plan familial",
@ -137,6 +138,8 @@
"enterCodeHint": "Saisir le code à 6 caractères de votre appli d'authentification",
"lostDeviceTitle": "Appareil perdu ?",
"twoFactorAuthTitle": "Authentification à deux facteurs",
"passkeyAuthTitle": "Vérification du code d'accès",
"verifyPasskey": "Vérifier le code d'accès",
"recoverAccount": "Récupérer un compte",
"enterRecoveryKeyHint": "Saisissez votre clé de récupération",
"recover": "Restaurer",
@ -190,6 +193,10 @@
"recoveryKeySaveDescription": "Nous ne stockons pas cette clé, veuillez enregistrer cette clé de 24 mots dans un endroit sûr.",
"doThisLater": "Plus tard",
"saveKey": "Enregistrer la clé",
"save": "Sauvegarder",
"send": "Envoyer",
"saveOrSendDescription": "Voulez-vous enregistrer ceci sur votre stockage (dossier Téléchargements par défaut) ou l'envoyer à d'autres applications ?",
"saveOnlyDescription": "Voulez-vous enregistrer ceci sur votre stockage (dossier Téléchargements par défaut) ?",
"back": "Retour",
"createAccount": "Créer un compte",
"passwordStrength": "Force du mot de passe : {passwordStrengthValue}",
@ -329,6 +336,7 @@
"offlineModeWarning": "Vous avez choisi de procéder sans sauvegarde. Veuillez prendre des sauvegardes manuelles pour vous assurer que vos codes sont sûrs.",
"showLargeIcons": "Afficher les grandes icônes",
"shouldHideCode": "Cacher les codes",
"doubleTapToViewHiddenCode": "Vous pouvez appuyer deux fois sur une entrée pour afficher le code",
"focusOnSearchBar": "Cibler le champ de recherche au démarrage de l'application",
"confirmUpdatingkey": "Êtes-vous sûr de vouloir mettre à jour la clé secrète ?",
"minimizeAppOnCopy": "Réduire l'application après la copie",
@ -336,5 +344,75 @@
"deleteCodeAuthMessage": "Authentification requise pour supprimer le code",
"showQRAuthMessage": "Authentification requise pour afficher le code QR",
"confirmAccountDeleteTitle": "Confirmer la suppression du compte",
"confirmAccountDeleteMessage": "Ce compte peut être lié à d'autres applications ente.\n\nVos données seront bientôt effacées de toutes les applications et votre compte sera définitivement supprimé."
"androidBiometricHint": "Vérifier lidentité",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
},
"androidBiometricNotRecognized": "Non reconnu. Veuillez réessayer.",
"@androidBiometricNotRecognized": {
"description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters."
},
"androidBiometricSuccess": "Parfait",
"@androidBiometricSuccess": {
"description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters."
},
"androidCancelButton": "Annuler",
"@androidCancelButton": {
"description": "Message showed on a button that the user can click to leave the current dialog. It is used on Android side. Maximum 30 characters."
},
"androidSignInTitle": "Authentification requise",
"@androidSignInTitle": {
"description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters."
},
"androidBiometricRequiredTitle": "Empreinte digitale requise",
"@androidBiometricRequiredTitle": {
"description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters."
},
"androidDeviceCredentialsRequiredTitle": "Identifiants de l'appareil requis",
"@androidDeviceCredentialsRequiredTitle": {
"description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters."
},
"androidDeviceCredentialsSetupDescription": "Identifiants de l'appareil requis",
"@androidDeviceCredentialsSetupDescription": {
"description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side."
},
"goToSettings": "Allez dans les paramètres",
"@goToSettings": {
"description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters."
},
"androidGoToSettingsDescription": "L'authentification biométrique n'est pas configurée sur votre appareil. Allez dans 'Paramètres > Sécurité' pour l'ajouter.",
"@androidGoToSettingsDescription": {
"description": "Message advising the user to go to the settings and configure biometric on their device. It shows in a dialog on Android side."
},
"iOSLockOut": "L'authentification biométrique est désactivée. Veuillez verrouiller et déverrouiller votre écran pour l'activer.",
"@iOSLockOut": {
"description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side."
},
"iOSGoToSettingsDescription": "L'authentification biométrique n'est pas configurée sur votre appareil. Veuillez activer Touch ID ou Face ID sur votre téléphone.",
"@iOSGoToSettingsDescription": {
"description": "Message advising the user to go to the settings and configure Biometrics for their device. It shows in a dialog on iOS side."
},
"iOSOkButton": "Ok",
"@iOSOkButton": {
"description": "Message showed on a button that the user can click to leave the current dialog. It is used on iOS side. Maximum 30 characters."
},
"noInternetConnection": "Aucune connexion internet",
"pleaseCheckYourInternetConnectionAndTryAgain": "Veuillez vérifier votre connexion internet puis réessayer.",
"signOutFromOtherDevices": "Déconnexion des autres appareils",
"signOutOtherBody": "Si vous pensez que quelqu'un connaît peut-être votre mot de passe, vous pouvez forcer tous les autres appareils utilisant votre compte à se déconnecter.",
"signOutOtherDevices": "Déconnecter les autres appareils",
"doNotSignOut": "Ne pas se déconnecter",
"hearUsWhereTitle": "Comment avez-vous entendu parler de Ente? (facultatif)",
"hearUsExplanation": "Nous ne suivons pas les installations d'applications. Il serait utile que vous nous disiez comment vous nous avez trouvés !",
"recoveryKeySaved": "Clé de récupération enregistrée dans le dossier Téléchargements !",
"waitingForBrowserRequest": "En attente de la requête du navigateur...",
"waitingForVerification": "En attente de vérification...",
"passkey": "Code d'accès",
"developerSettingsWarning": "Êtes-vous sûr de vouloir modifier les paramètres du développeur ?",
"developerSettings": "Paramètres du développeur",
"serverEndpoint": "Point de terminaison serveur",
"invalidEndpoint": "Point de terminaison non valide",
"invalidEndpointMessage": "Désolé, le point de terminaison que vous avez entré n'est pas valide. Veuillez en entrer un valide puis réessayez.",
"endpointUpdatedMessage": "Point de terminaison mis à jour avec succès",
"customEndpoint": "Connecté à {endpoint}"
}

View file

@ -77,13 +77,11 @@
"data": "נתונים",
"importCodes": "ייבא קוד",
"importTypePlainText": "טקסט רגיל",
"importTypeEnteEncrypted": "ייצוא ente מוצפן",
"passwordForDecryptingExport": "סיסמא כדי לפענח יצוא",
"passwordEmptyError": "הסיסמה אינה יכולה להיות ריקה",
"importFromApp": "יבא קודים מ-{appName}",
"importGoogleAuthGuide": "יצא את החשבונות שלך מ-Google Authenticator לקוד QR תוך שימוש באפשרות \"Transfer Accounts\". אז, תוך שימוש במכשיר אחר, סרוק את הקוד QR.",
"importSelectJsonFile": "בחר קובץ JSON",
"importEnteEncGuide": "בחר את קובץ ה-JSON המוצפן שייוצא מ-ente",
"importRaivoGuide": "השתמש באפוציה של \"Export OTPs to Zip archive\" בהגדרות של Raivo.",
"importAegisGuide": "אנא השתמש באפשרות של \"ייצוא של הכספת\" בתוך ההגדרות של Aegis.",
"exportCodes": "ייצא קודים",
@ -108,22 +106,18 @@
"copied": "הועתק",
"pleaseTryAgain": "אנא נסה שנית",
"existingUser": "משתמש קיים",
"newUser": "חדש בente",
"delete": "למחוק",
"enterYourPasswordHint": "הכנס סיסמא",
"forgotPassword": "שכחתי סיסמה",
"oops": "אופס",
"suggestFeatures": "הציעו מאפיינים",
"faq": "שאלות נפוצות",
"faq_q_1": "כמה מאובטח ente Auth?",
"faq_a_1": "כל הקודים שאתה מגבה דרך ente מאוחסנים מקצה לקצה בהצפנה. הכוונה שרק אתה יכול לגשת לקודים שלך. האפליקציות שלנו הם מפותחות דרך קוד פתוח והקריפטוגרפיה שלנו מבוקרת חיצונית.",
"faq_q_2": "האם ישנה אפשרות להשתמש בקודים שלי במחשב?",
"faq_a_2": "אתה יכול לגשת לקודים שלך ברשת ב- auth.ente.io.",
"faq_q_3": "איך אפשר למחוק קודים?",
"faq_a_3": "אתה יכול למחוק את הקוד על-ידי החלקה שמאלה על הפריט הזה.",
"faq_q_4": "איך אפשר לתמוך בפרויקט זה?",
"faq_a_4": "אתה יכול לתמוך בפיתוח של הפרויקט הזה על ידי שתירשם לאפליקצית תמונות שלנו ב-ente.io.",
"faq_q_5": "איך אני יכול להפעיל מנעול FaceID ב-ente Auth",
"faq_a_5": "אתה יכול להפעיל מנעול FaceID תחת הגדרות -> אבטחה -> מסך נעילה.",
"somethingWentWrongMessage": "משהו השתבש, אנא נסה שנית",
"leaveFamily": "עזוב משפחה",

View file

@ -78,14 +78,12 @@
"data": "Dati",
"importCodes": "Importa codici",
"importTypePlainText": "Testo in chiaro",
"importTypeEnteEncrypted": "ente Esportazione criptata",
"passwordForDecryptingExport": "Password per decriptare il file esportato",
"passwordEmptyError": "La password è obbligatoria",
"importFromApp": "Importa codici da {appName}",
"importGoogleAuthGuide": "Esporta i tuoi account da Google Authenticator in un codice QR utilizzando l'opzione \"Trasferisci Account\". Quindi, usando un altro dispositivo, scansiona il codice QR.\n\nSuggerimento: Puoi usare la webcam del tuo computer portatile per scattare una foto del codice QR.",
"importSelectJsonFile": "Seleziona file JSON",
"importSelectAppExport": "Seleziona il file di esportazione {appName}",
"importEnteEncGuide": "Seleziona il file JSON criptato esportato da ente",
"importRaivoGuide": "Utilizza l'opzione \"Esporta i codici OTP in archivio Zip\" nelle impostazioni di Raivo.\n\nEstrai il file zip e importa il file JSON.",
"importBitwardenGuide": "Utilizzare l'opzione \"Esporta vault\" all'interno di Bitwarden Tools e importa il file JSON non crittografato.",
"importAegisGuide": "Usa l'opzione \"Esporta la cassaforte\" nelle impostazioni di Aegis.\n\nSe la tua cassaforte è criptata, dovrai inserire la password della cassaforte per decriptarla.",
@ -114,22 +112,18 @@
"copied": "Copiato",
"pleaseTryAgain": "Per favore riprova",
"existingUser": "Accedi",
"newUser": "Nuovo utente",
"delete": "Cancella",
"enterYourPasswordHint": "Inserisci la tua password",
"forgotPassword": "Password dimenticata",
"oops": "Oops",
"suggestFeatures": "Suggerisci funzionalità",
"faq": "FAQ",
"faq_q_1": "Quanto è sicuro ente Auth?",
"faq_a_1": "Tutti i codici di cui fai il backup tramite ente sono memorizzati con crittografia end-to-end. Ciò significa che solo tu puoi accedere ai tuoi codici. Le nostre app sono open source e la nostra crittografia è stata verificata esternamente.",
"faq_q_2": "Posso accedere ai miei codici sul desktop?",
"faq_a_2": "Puoi accedere ai tuoi codici sul web @ auth.ente.io.",
"faq_q_3": "Come posso cancellare i codici?",
"faq_a_3": "Puoi eliminare un codice scorrendo il dito a sinistra sul codice in questione.",
"faq_q_4": "Come posso supportare questo progetto?",
"faq_a_4": "Puoi supportare lo sviluppo di questo progetto abbonandoti alla nostra app Photos @ ente.io.",
"faq_q_5": "Come posso abilitare il blocco FaceID in ente Auth",
"faq_a_5": "Puoi abilitare il blocco FaceID in Impostazioni → Sicurezza → Schermata di blocco.",
"somethingWentWrongMessage": "Qualcosa è andato storto, per favore riprova",
"leaveFamily": "Abbandona il piano famiglia",
@ -343,7 +337,6 @@
"deleteCodeAuthMessage": "Autenticarsi per cancellare il codice",
"showQRAuthMessage": "Autenticarsi per mostrare il codice QR",
"confirmAccountDeleteTitle": "Conferma l'eliminazione dell'account",
"confirmAccountDeleteMessage": "Questo account è collegato ad altre app di ente, se ne utilizzi.\n\nI tuoi dati caricati, su tutte le app di ente, saranno pianificati per la cancellazione e il tuo account verrà eliminato definitivamente.",
"androidBiometricHint": "Verifica l'identità",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,13 @@
"data": "データ",
"importCodes": "コードをインポート",
"importTypePlainText": "プレーンテキスト",
"importTypeEnteEncrypted": "ente 暗号化エクスポート",
"passwordForDecryptingExport": "復号化用パスワード",
"passwordEmptyError": "パスワードは空欄にできません",
"importFromApp": "{appName} からコードをインポート",
"importGoogleAuthGuide": "Google Authenticator の \"アカウントを転送\" オプションを使用してあなたのアカウントを QR コードにエクスポートしてください。その後、他のデバイスで QR コードを読み取ってください。\n\nヒント: ノートパソコンのウェブカメラを使用して QR コードを撮影することができます。",
"importSelectJsonFile": "JSON ファイルを選択",
"importSelectAppExport": "{appName} のエクスポートファイルを選択",
"importEnteEncGuide": "ente からエクスポートされた暗号化 JSON ファイルを選択",
"importEnteEncGuide": "Enteからエクスポートした暗号化されたJSONファイルを選択してください",
"importRaivoGuide": "Raivo の設定の \"OTP を zip アーカイブにエクスポート\" を使用してください。\n\nzip ファイルを解凍し JSON ファイルをインポートしてください。",
"importBitwardenGuide": "Bitwarden Tools の \"保管庫のエクスポート\" オプションを使用後、平文の JSON ファイルをインポートしてください。",
"importAegisGuide": "Aegis の設定の \"保管庫をエクスポート\" を使用してください。\n\n保管庫が暗号化されている場合、保管庫を復号するためにパスワードの入力が必要になります。",
@ -115,22 +114,21 @@
"copied": "コピーしました",
"pleaseTryAgain": "再度お試しください",
"existingUser": "既存のユーザー",
"newUser": "ente 新規ユーザー",
"newUser": "Enteを初めて使用",
"delete": "削除",
"enterYourPasswordHint": "パスワードを入力してください",
"forgotPassword": "パスワードを忘れた場合",
"oops": "おっと",
"suggestFeatures": "機能を提案",
"faq": "FAQ",
"faq_q_1": "ente Auth はどのくらい安全ですか?",
"faq_a_1": "ente でバックアップされたすべてのコードはエンドツーエンドで暗号化されて保管されます。これはあなただけがコードにアクセスできることを意味します。私たちのアプリはオープンソースであり、私たちの暗号は外部有識者によって検証済みです。",
"faq_q_1": "Authはどのくらい安全ですか",
"faq_q_2": "パソコンから私のコードにアクセスできますか?",
"faq_a_2": "auth.ente.io で Web からコードにアクセス可能です。",
"faq_q_3": "コードを削除するにはどうすればいいですか?",
"faq_a_3": "その項目を左にスワイプすることでコードを削除できます。",
"faq_q_4": "このプロジェクトを支援するにはどうすればいいですか?",
"faq_a_4": "ente.io で私たちの写真アプリを購読することでこのプロジェクトの開発を支援できます。",
"faq_q_5": "ente Auth で FaceID ロックを有効にするにはどうすればいいですか?",
"faq_q_5": "AuthでFaceIDロックを有効にするにはどうすればいいですか?",
"faq_a_5": "設定→セキュリティ→画面のロックから FaceID ロックを有効にできます。",
"somethingWentWrongMessage": "問題が発生しました、再試行してください",
"leaveFamily": "ファミリープランから退会",
@ -199,6 +197,10 @@
"recoveryKeySaveDescription": "私たちはこのキーを保存しません。この 24 単語のキーを安全な場所に保存してください。",
"doThisLater": "後で行う",
"saveKey": "キーを保存",
"save": "保存",
"send": "送信",
"saveOrSendDescription": "これをストレージ (デフォルトではダウンロードフォルダ) に保存しますか、もしくは他のアプリに送信しますか?",
"saveOnlyDescription": "これをストレージに保存しますか? (デフォルトではダウンロードフォルダに保存)",
"back": "戻る",
"createAccount": "アカウント作成",
"passwordStrength": "パスワードの強度: {passwordStrengthValue}",
@ -346,7 +348,6 @@
"deleteCodeAuthMessage": "コードを削除するためには認証が必要です",
"showQRAuthMessage": "QR コードを表示するためには認証が必要です",
"confirmAccountDeleteTitle": "アカウントの削除に同意",
"confirmAccountDeleteMessage": "このアカウントは他の ente アプリも使用している場合はそれらに結びつけられています。\n\nすべての ente アプリであなたがアップロードしたデータは削除がスケジュールされ、あなたのアカウントは永久に削除されます。",
"androidBiometricHint": "本人を確認する",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -407,6 +408,7 @@
"doNotSignOut": "サインアウトしない",
"hearUsWhereTitle": "Ente についてどのようにお聞きになりましたか?(任意)",
"hearUsExplanation": "私たちはアプリのインストールを追跡していません。私たちをお知りになった場所を教えてください!",
"recoveryKeySaved": "リカバリキーをダウンロードフォルダに保存しました!",
"waitingForBrowserRequest": "ブラウザのリクエストを待っています...",
"waitingForVerification": "認証を待っています...",
"passkey": "パスキー",

View file

@ -74,7 +74,6 @@
"data": "მონაცემები",
"importCodes": "კოდების იმპორტირება",
"importTypePlainText": "სტანდარტული ტექსტი",
"importTypeEnteEncrypted": "ente დაშიფრული ექსპორტი",
"passwordForDecryptingExport": "ექსპორტის გაშიფრვის პაროლი",
"passwordEmptyError": "პაროლის ველი არ შეიძლება იყოს ცარიელი",
"emailVerificationToggle": "ელექტრონული ფოსტის ვერიფიკაცია",

View file

@ -78,7 +78,7 @@
"data": "Gegevens",
"importCodes": "Codes importeren",
"importTypePlainText": "Kale tekst",
"importTypeEnteEncrypted": "ente versleutelde export",
"importTypeEnteEncrypted": "Ente versleutelde export",
"passwordForDecryptingExport": "Wachtwoord voor het decoderen van export",
"passwordEmptyError": "Wachtwoord kan niet leeg zijn",
"importFromApp": "Importeer codes van {appName}",
@ -115,15 +115,15 @@
"copied": "Gekopieerd",
"pleaseTryAgain": "Probeer het nog eens",
"existingUser": "Bestaande gebruiker",
"newUser": "Nieuw bij ente",
"newUser": "Nieuw bij Ente",
"delete": "Verwijderen",
"enterYourPasswordHint": "Voer je wachtwoord in",
"forgotPassword": "Wachtwoord vergeten",
"oops": "Oeps",
"suggestFeatures": "Features voorstellen",
"faq": "Veelgestelde vragen",
"faq_q_1": "Hoe veilig is ente Auth?",
"faq_a_1": "Alle codes in ente zijn versleuteld opgeslagen met end-to-end encryptie. Dit betekent dat alleen jij toegang hebt tot je codes. Onze apps zijn open source en onze cryptografie is extern gecontroleerd.",
"faq_q_1": "Hoe veilig is Ente Auth?",
"faq_a_1": "Alle codes in Auth zijn versleuteld opgeslagen met end-to-end encryptie. Dit betekent dat alleen jij toegang hebt tot je codes. Onze apps zijn open source en onze cryptografie is extern gecontroleerd.",
"faq_q_2": "Kan ik toegang krijgen tot mijn codes op desktop?",
"faq_a_2": "U heeft toegang tot uw codes op het web @ auth.ente.io.",
"faq_q_3": "Hoe kan ik codes verwijderen?",
@ -199,6 +199,10 @@
"recoveryKeySaveDescription": "We slaan deze code niet op, bewaar deze code met 24 woorden op een veilige plaats.",
"doThisLater": "Doe dit later",
"saveKey": "Sleutel opslaan",
"save": "Opslaan",
"send": "Verzenden",
"saveOrSendDescription": "Wil je dit opslaan naar je opslagruimte (Downloads map) of naar andere apps versturen?",
"saveOnlyDescription": "Wil je dit opslaan naar je opslagruimte (Downloads map)?",
"back": "Terug",
"createAccount": "Account aanmaken",
"passwordStrength": "Wachtwoord sterkte: {passwordStrengthValue}",
@ -346,7 +350,7 @@
"deleteCodeAuthMessage": "Authenticeren om code te verwijderen",
"showQRAuthMessage": "Authenticeren om QR-code te tonen",
"confirmAccountDeleteTitle": "Account verwijderen bevestigen",
"confirmAccountDeleteMessage": "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\n\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten.",
"confirmAccountDeleteMessage": "Dit account is gekoppeld aan andere Ente apps, als je er gebruik van maakt.\n\nJe geüploade gegevens worden in alle Ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle Ente diensten.",
"androidBiometricHint": "Identiteit verifiëren",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -407,6 +411,7 @@
"doNotSignOut": "Niet uitloggen",
"hearUsWhereTitle": "Hoe hoorde je over Ente? (optioneel)",
"hearUsExplanation": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!",
"recoveryKeySaved": "Herstelsleutel opgeslagen in de Downloads map!",
"waitingForBrowserRequest": "Wachten op browserverzoek...",
"waitingForVerification": "Wachten op verificatie...",
"passkey": "Passkey",

View file

@ -78,13 +78,11 @@
"data": "Dane",
"importCodes": "Importuj kody",
"importTypePlainText": "Zwykły tekst",
"importTypeEnteEncrypted": "Zaszyfrowany eksport ente",
"passwordForDecryptingExport": "Hasło do odszyfrowania eksportu",
"passwordEmptyError": "Pole hasło nie może być puste",
"importFromApp": "Importuj kody z {appName}",
"importGoogleAuthGuide": "Wyeksportuj twoje konta z Google Authenticator do kodu QR używając opcji \"Przenieś konta\". Potem używając innego urządzenia, zeskanuj kod QR.",
"importSelectJsonFile": "Wybierz plik JSON",
"importEnteEncGuide": "Wybierz zaszyfrowany plik JSON wyeksportowany z ente",
"importRaivoGuide": "Użyj opcji \"Eksportuj OTP do archiwum ZIP\" w Ustawieniach Raivo.\n\nWyodrębnij plik zip i zaimportuj plik JSON.",
"importAegisGuide": "Użyj opcji \"Eksportuj sejf\" w ustawieniach Aegis.\n\nJeśli twój sejf jest zaszyfrowany, musisz wprowadzić hasło sejfu, aby odszyfrować sejf.",
"exportCodes": "Eksportuj kody",
@ -110,22 +108,18 @@
"copied": "Skopiowano",
"pleaseTryAgain": "Proszę spróbować ponownie",
"existingUser": "Istniejący użytkownik",
"newUser": "Nowy do ente",
"delete": "Usuń",
"enterYourPasswordHint": "Wprowadź swoje hasło",
"forgotPassword": "Nie pamiętam hasła",
"oops": "Ups",
"suggestFeatures": "Zaproponuj funkcje",
"faq": "Najczęściej zadawane pytania (FAQ)",
"faq_q_1": "Jak bezpieczny jest ente Auth?",
"faq_a_1": "Wszystkie kody, których tworzysz kopię zapasową za pomocą ente są przechowywane zaszyfrowane end-to-end. Oznacza to, że tylko Ty możesz uzyskać dostęp do swoich kodów. Nasze aplikacje są otwarto-źródłowe, a nasza kryptografia została poddana sprawdzeniu z zewnątrz.",
"faq_q_2": "Czy mogę uzyskać dostęp do moich kodów na komputerze?",
"faq_a_2": "Możesz uzyskać dostęp do swoich kodów na stronie auth.ente.io.",
"faq_q_3": "Jak mogę usunąć kody?",
"faq_a_3": "Możesz usunąć kod, przesuwając go w lewo.",
"faq_q_4": "Jak mogę wesprzeć ten projekt?",
"faq_a_4": "Możesz wspierać rozwój tego projektu, subskrybując do naszej aplikacji Zdjęcia na ente.io.",
"faq_q_5": "Jak mogę włączyć blokadę FaceID w ente Auth?",
"faq_a_5": "Możesz włączyć blokadę FaceID w Ustawienia → Bezpieczeństwo→ Ekran blokady.",
"somethingWentWrongMessage": "Coś poszło nie tak. Proszę, spróbuj ponownie",
"leaveFamily": "Opuść rodzinę",
@ -338,7 +332,6 @@
"deleteCodeAuthMessage": "Uwierzytelnij, aby usunąć kod",
"showQRAuthMessage": "Uwierzytelnij, aby pokazać kod QR",
"confirmAccountDeleteTitle": "Potwierdź usunięcie konta",
"confirmAccountDeleteMessage": "To konto jest połączone z innymi aplikacjami ente, jeśli ich używasz.\n\nTwoje przesłane dane, we wszystkich aplikacjach ente, zostaną zaplanowane do usunięcia, a Twoje konto zostanie trwale usunięte.",
"androidBiometricNotRecognized": "Nie rozpoznano. Spróbuj ponownie.",
"@androidBiometricNotRecognized": {
"description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,12 @@
"data": "Dados",
"importCodes": "Importar códigos",
"importTypePlainText": "Texto simples",
"importTypeEnteEncrypted": "ente Exportação criptografada",
"passwordForDecryptingExport": "Senha para descriptografar a exportação",
"passwordEmptyError": "O campo senha não pode estar vazio",
"importFromApp": "Importar códigos do {appName}",
"importGoogleAuthGuide": "Exporte suas contas do Google Authenticator para um QR code usando a opção \"Transferir contas\". Então, usando outro dispositivo, escaneie o QR code.\n\nDica: Você pode usar a câmera do seu notebook para fotografar o QR code.",
"importSelectJsonFile": "Selecione o arquivo JSON",
"importSelectAppExport": "Selecione o arquivo de exportação do aplicativo {appName}",
"importEnteEncGuide": "Selecione o arquivo JSON criptografado exportado pelo ente",
"importRaivoGuide": "Use a opção \"Exportar OTPs para arquivo Zip\" nas configurações do Raivo.\n\nExtraia o arquivo zip e importe o arquivo JSON.",
"importBitwardenGuide": "Use a opção \"Exportar cofre\" nas configurações do Bitwarden e importe o arquivo JSON não criptografado.",
"importAegisGuide": "Use a opção \"Exportar cofre\" nas Configurações do Aegis.\n\nSe o seu cofre estiver criptografado, você precisará inserir a senha do cofre para descriptografá-lo.",
@ -115,22 +113,18 @@
"copied": "Copiado",
"pleaseTryAgain": "Por favor, tente novamente",
"existingUser": "Usuário Existente",
"newUser": "Novo no ente",
"delete": "Excluir",
"enterYourPasswordHint": "Insira sua senha",
"forgotPassword": "Esqueci a senha",
"oops": "Oops",
"suggestFeatures": "Sugerir funcionalidades",
"faq": "Perguntas frequentes",
"faq_q_1": "Quão seguro é o ente Auth?",
"faq_a_1": "Todos os códigos que você faz cópia de segurança via ente são armazenados criptografados de ponta a ponta. Isso significa que somente você pode acessar seus códigos. Nossos aplicativos são de código aberto e nossa criptografia foi auditada externamente.",
"faq_q_2": "Eu posso acessar meus códigos no computador?",
"faq_a_2": "Você pode acessar seus códigos na web em auth.ente.io.",
"faq_q_3": "Como faço para excluir códigos?",
"faq_a_3": "Você pode excluir um código deslizando para a esquerda sobre esse item.",
"faq_q_4": "Como posso apoiar este projeto?",
"faq_a_4": "Você pode apoiar o desenvolvimento deste projeto assinando nosso aplicativo de Fotos em ente.io.",
"faq_q_5": "Como posso ativar o bloqueio facial no ente Auth",
"faq_a_5": "Você pode ativar o bloqueio facial em Configurações → Segurança → Tela de bloqueio.",
"somethingWentWrongMessage": "Algo deu errado. Por favor, tente outra vez",
"leaveFamily": "Sair da família",
@ -199,6 +193,10 @@
"recoveryKeySaveDescription": "Não armazenamos essa chave, por favor, salve essa chave de 24 palavras em um lugar seguro.",
"doThisLater": "Fazer isso mais tarde",
"saveKey": "Salvar chave",
"save": "Salvar",
"send": "Enviar",
"saveOrSendDescription": "Você deseja salvar isso no seu armazenamento (pasta de downloads por padrão) ou enviá-lo para outros aplicativos?",
"saveOnlyDescription": "Você deseja salvar isto no seu armazenamento (pasta de downloads por padrão)?",
"back": "Voltar",
"createAccount": "Criar uma conta",
"passwordStrength": "Força da senha: {passwordStrengthValue}",
@ -346,7 +344,6 @@
"deleteCodeAuthMessage": "Autenticar para excluir o código",
"showQRAuthMessage": "Autenticar para mostrar o QR code",
"confirmAccountDeleteTitle": "Confirmar exclusão de conta",
"confirmAccountDeleteMessage": "Esta conta está vinculada a outros aplicativos ente, se você usa algum.\n\nSeus dados enviados, em todos os aplicativos ente, serão agendados para exclusão, e sua conta será excluída permanentemente.",
"androidBiometricHint": "Verificar identidade",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -407,6 +404,7 @@
"doNotSignOut": "Não encerrar sessão",
"hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)",
"hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!",
"recoveryKeySaved": "Chave de recuperação salva na pasta Downloads!",
"waitingForBrowserRequest": "Aguardando solicitação do navegador...",
"waitingForVerification": "Esperando por verificação...",
"passkey": "Chave de acesso",

View file

@ -78,14 +78,12 @@
"data": "Данные",
"importCodes": "Импортировать коды",
"importTypePlainText": "Обычный текст",
"importTypeEnteEncrypted": "ente Зашифрованный экспорт",
"passwordForDecryptingExport": "Пароль для расшифровки экспорта",
"passwordEmptyError": "Пароль не может быть пустым",
"importFromApp": "Импорт кодов из {appName}",
"importGoogleAuthGuide": "Экспортируйте учетные записи из Google Authenticator в QR-код, используя опцию «Перенести учетные записи». Затем с помощью другого устройства отсканируйте QR-код.\n\nСовет: Чтобы сфотографировать QR-код, можно воспользоваться веб-камерой ноутбука.",
"importSelectJsonFile": "Выбрать JSON-файл",
"importSelectAppExport": "Выбрать файл экспорта {appName}",
"importEnteEncGuide": "Выберите зашифрованный JSON-файл, экспортированный из ente",
"importRaivoGuide": "Используйте опцию «Export OTPs to Zip archive» в настройках Raivo.\n\nРаспакуйте zip-архив и импортируйте JSON-файл.",
"importBitwardenGuide": "Используйте опцию \"Экспортировать хранилище\" в Bitwarden Tools и импортируйте незашифрованный JSON файл.",
"importAegisGuide": "Используйте опцию «Экспортировать хранилище» в настройках Aegis.\n\nЕсли ваше хранилище зашифровано, то для его расшифровки потребуется ввести пароль хранилища.",
@ -113,22 +111,18 @@
"copied": "Скопировано",
"pleaseTryAgain": "Пожалуйста, попробуйте ещё раз",
"existingUser": "Существующий пользователь",
"newUser": "Новый аккаунт",
"delete": "Удалить",
"enterYourPasswordHint": "Введите пароль",
"forgotPassword": "Забыл пароль",
"oops": "Ой",
"suggestFeatures": "Предложить идеи",
"faq": "FAQ",
"faq_q_1": "Насколько безопасен ente Auth?",
"faq_a_1": "Все коды, которые вы резервируете с помощью ente, хранятся в зашифрованном виде. Это означает, что только вы можете получить доступ к своим кодам. Наши приложения имеют открытый исходный код, а наша криптография прошла внешний аудит.",
"faq_q_2": "Могу ли я получить доступ к моим кодам на компьютере?",
"faq_a_2": "Вы можете получить доступ к своим кодам на сайте @ auth.ente.io.",
"faq_q_3": "Как я могу удалить коды?",
"faq_a_3": "Вы можете удалить код, проведя пальцем влево по этому элементу.",
"faq_q_4": "Как я могу поддержать этот проект?",
"faq_a_4": "Вы можете поддержать развитие этого проекта, подписавшись на наше приложение Photos @ ente.io.",
"faq_q_5": "Как мне включить блокировку FaceID в ente Auth?",
"faq_a_5": "Вы можете включить блокировку FaceID в Настройки → Безопасность → Экран блокировки.",
"somethingWentWrongMessage": "Что-то пошло не так. Попробуйте еще раз",
"leaveFamily": "Покинуть семью",
@ -342,7 +336,6 @@
"deleteCodeAuthMessage": "Аутентификация для удаления кода",
"showQRAuthMessage": "Аутентификация для отображения QR-кода",
"confirmAccountDeleteTitle": "Подтвердить удаление аккаунта",
"confirmAccountDeleteMessage": "Эта учетная запись связана с другими приложениями ente, если вы ими пользуетесь.\n\nЗагруженные вами данные во всех приложениях ente будут запланированы к удалению, а ваша учетная запись будет удалена без возможности восстановления.",
"androidBiometricHint": "Подтвердите личность",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -73,7 +73,6 @@
"oops": "Hoppsan",
"suggestFeatures": "Föreslå funktionalitet",
"faq": "FAQ",
"faq_q_1": "Hur säkert är ente Auth?",
"scan": "Skanna",
"twoFactorAuthTitle": "Tvåfaktorsautentisering",
"enterRecoveryKeyHint": "Ange din återställningsnyckel",
@ -105,6 +104,7 @@
"recoveryKeyCopiedToClipboard": "Återställningsnyckel kopierad till urklipp",
"recoveryKeyOnForgotPassword": "Om du glömmer ditt lösenord är det enda sättet du kan återställa dina data med denna nyckel.",
"saveKey": "Spara nyckel",
"save": "Spara",
"back": "Tillbaka",
"createAccount": "Skapa konto",
"password": "Lösenord",

View file

@ -78,14 +78,12 @@
"data": "ሓበሬታ",
"importCodes": "ኮድ ኣእቱ",
"importTypePlainText": "Plain text",
"importTypeEnteEncrypted": "ente Encrypted export",
"passwordForDecryptingExport": "Password to decrypt export",
"passwordEmptyError": "Password can not be empty",
"importFromApp": "Import codes from {appName}",
"importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.",
"importSelectJsonFile": "Select JSON file",
"importSelectAppExport": "Select {appName} export file",
"importEnteEncGuide": "Select the encrypted JSON file exported from ente",
"importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.",
"importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.",
"importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.",
@ -114,22 +112,18 @@
"copied": "Copied",
"pleaseTryAgain": "Please try again",
"existingUser": "Existing User",
"newUser": "New to ente",
"delete": "Delete",
"enterYourPasswordHint": "Enter your password",
"forgotPassword": "Forgot password",
"oops": "ዉዉኡ",
"suggestFeatures": "Suggest features",
"faq": "FAQ",
"faq_q_1": "How secure is ente Auth?",
"faq_a_1": "All codes you backup via ente is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.",
"faq_q_2": "Can I access my codes on desktop?",
"faq_a_2": "You can access your codes on the web @ auth.ente.io.",
"faq_q_3": "How can I delete codes?",
"faq_a_3": "You can delete a code by swiping left on that item.",
"faq_q_4": "How can I support this project?",
"faq_a_4": "You can support the development of this project by subscribing to our Photos app @ ente.io.",
"faq_q_5": "How can I enable FaceID lock in ente Auth",
"faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.",
"somethingWentWrongMessage": "Something went wrong, please try again",
"leaveFamily": "Leave family",
@ -343,7 +337,6 @@
"deleteCodeAuthMessage": "Authenticate to delete code",
"showQRAuthMessage": "Authenticate to show QR code",
"confirmAccountDeleteTitle": "Confirm account deletion",
"confirmAccountDeleteMessage": "This account is linked to other ente apps, if you use any.\n\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.",
"androidBiometricHint": "Verify identity",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,12 @@
"data": "Veri",
"importCodes": "Kodu içe aktar",
"importTypePlainText": "Salt metin",
"importTypeEnteEncrypted": "ente Şifreli dışa aktarma",
"passwordForDecryptingExport": "Dışa aktarımın şifresini çözmek için parola",
"passwordEmptyError": "Şifre boş olamaz",
"importFromApp": "Kodları {appName} uygulamasından içe aktarın",
"importGoogleAuthGuide": "\"Hesapları Aktar\" seçeneğini kullanarak hesaplarınızı Google Authenticator'dan bir QR koduna aktarın. Ardından başka bir cihaz kullanarak QR kodunu tarayın.\n\nİpucu: QR kodunun fotoğrafını çekmek için dizüstü bilgisayarınızın kamerasını kullanabilirsiniz.",
"importSelectJsonFile": "JSON dosyasını seçin",
"importSelectAppExport": "{appName} dışarı aktarma dosyasını seçin",
"importEnteEncGuide": "Ente'den dışa aktarılan şifrelenmiş JSON dosyasını seçin",
"importRaivoGuide": "Raivo'nun ayarlarında \"OTP'leri Zip arşivine aktar\" seçeneğini kullanın.\n\nZip dosyasını çıkarın ve JSON dosyasını içe aktarın.",
"importBitwardenGuide": "Bitwarden Tools içindeki \"Kasayı dışa aktar\" seçeneğini kullanın ve şifrelenmemiş JSON dosyasını içe aktarın.",
"importAegisGuide": "Aegis'in Ayarlarında \"Kasayı dışa aktar\" seçeneğini kullanın.\n\nKasanız şifrelenmişse, kasanın şifresini çözmek için kasa parolasını girmeniz gerekecektir.",
@ -115,22 +113,18 @@
"copied": "Kopyalandı",
"pleaseTryAgain": "Lütfen tekrar deneyin",
"existingUser": "Mevcut kullanıcı",
"newUser": "Yeni ente kullanıcısı",
"delete": "Sil",
"enterYourPasswordHint": "Parolanızı girin",
"forgotPassword": "Şifremi unuttum",
"oops": "Hay aksi",
"suggestFeatures": "Özellik önerin",
"faq": "SSS",
"faq_q_1": "Ente Auth ne kadar güvenli?",
"faq_a_1": "Ente aracılığıyla yedeklediğiniz tüm kodlar uçtan uca şifrelenmiş olarak saklanır. Bu, kodlarınıza yalnızca sizin erişebileceğiniz anlamına gelir. Uygulamalarımız açık kaynaklıdır ve kriptografimiz harici olarak denetlenmiştir.",
"faq_q_2": "Kodlarıma masaüstünden erişebilir miyim?",
"faq_a_2": "Kodlarınıza internet üzerinden @ auth.ente.io adresinden erişebilirsiniz.",
"faq_q_3": "Kodları nasıl silebilirim?",
"faq_a_3": "Bir kodu, o öğenin üzerinde sola kaydırarak silebilirsiniz.",
"faq_q_4": "Bu projeye nasıl destek olabilirim?",
"faq_a_4": "Fotoğraflar uygulamamıza @ ente.io abone olarak bu projenin geliştirilmesine destek olabilirsiniz.",
"faq_q_5": "FaceID kilidini ente Auth'ta nasıl etkinleştirebilirim",
"faq_a_5": "FaceID kilidini Ayarlar → Güvenlik → Kilit Ekranı altında etkinleştirebilirsiniz.",
"somethingWentWrongMessage": "Bir şeyler ters gitti, lütfen tekrar deneyin",
"leaveFamily": "Aile planından ayrıl",
@ -344,7 +338,6 @@
"deleteCodeAuthMessage": "Kodu silmek için doğrulama yapın",
"showQRAuthMessage": "QR kodunu göstermek için doğrulama yapın",
"confirmAccountDeleteTitle": "Hesap silme işlemini onayla",
"confirmAccountDeleteMessage": "Bu hesap, eğer kullanıyorsanız, diğer ente uygulamalarıyla bağlantılıdır.\n\nTüm ente uygulamalarında yüklediğiniz veriler silinmek üzere programlanacak ve hesabınız kalıcı olarak silinecektir.",
"androidBiometricHint": "Kimliği doğrula",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,12 @@
"data": "Dữ liệu",
"importCodes": "Nhập mã",
"importTypePlainText": "Văn bản thuần",
"importTypeEnteEncrypted": "xuất ente đã mã hóa",
"passwordForDecryptingExport": "Mật khẩu để giải mã xuất",
"passwordEmptyError": "Mật khẩu không thể để trống",
"importFromApp": "Nhập mã từ {appName}",
"importGoogleAuthGuide": "Xuất dữ liệu tài khoản của bạn từ Google Authenticator sang mã QR bằng tùy chọn \"Chuyển tài khoản\". Sau đó dùng thiết bị khác quét mã QR.",
"importSelectJsonFile": "Chọn tệp JSON",
"importSelectAppExport": "Chọn {appName} tệp dữ liệu xuất",
"importEnteEncGuide": "Chọn tệp JSON được mã hóa đã xuất từ ente",
"importRaivoGuide": "Sử dụng tùy chọn \"Xuất OTP sang lưu trữ Zip\" trong cài đặt của Raivo.",
"importBitwardenGuide": "Sử dụng tùy chọn \"Xuất vault\" trong công cụ Bitwarden và nhập tệp JSON không được mã hóa.",
"importAegisGuide": "Nếu vault của bạn được mã hóa, bạn sẽ cần nhập mật khẩu vault để giải mã vault.",
@ -114,22 +112,18 @@
"copied": "\u001dĐã sao chép",
"pleaseTryAgain": "Vui lòng thử lại",
"existingUser": "Người dùng hiện tại",
"newUser": "Mới tham gia ente",
"delete": "Xóa",
"enterYourPasswordHint": "Nhập mật khẩu của bạn",
"forgotPassword": "Quên mật khẩu",
"oops": "Rất tiếc",
"suggestFeatures": "Tính năng đề nghị",
"faq": "Câu hỏi thường gặp",
"faq_q_1": "Mức độ an toàn của ente Auth như thế nào?",
"faq_a_1": "Tất cả các mã bạn sao lưu qua ente đều được lưu trữ dưới dạng mã hóa đầu cuối. Điều này có nghĩa là chỉ bạn mới có thể truy cập mã của mình. Ứng dụng của chúng tôi là nguồn mở và mật mã của chúng tôi đã được kiểm toán độc lập.",
"faq_q_2": "Tôi có thể truy cập mã của mình trên máy tính không?",
"faq_a_2": "Bạn có thể truy cập mã của mình trên web @ auth.ente.io.",
"faq_q_3": "Làm cách nào để xóa mã?",
"faq_a_3": "Bạn có thể xóa mã bằng cách vuốt sang trái vào mục đó.",
"faq_q_4": "Tôi có thể hỗ trợ dự án này như thế nào?",
"faq_a_4": "Bạn có thể hỗ trợ sự phát triển của dự án này bằng cách đăng ký ứng dụng Ảnh @ ente.io của chúng tôi.",
"faq_q_5": "Tôi có thể bật khóa FaceID trong ente Auth như thế nào",
"faq_a_5": "Bạn có thể bật khóa FaceID trong Cài đặt → Bảo mật → Màn hình khóa.",
"somethingWentWrongMessage": "Phát hiện có lỗi, xin thử lại",
"leaveFamily": "Rời khỏi gia đình",
@ -343,7 +337,6 @@
"deleteCodeAuthMessage": "Xác minh để xóa mã",
"showQRAuthMessage": "Xác minh để hiển thị mã QR",
"confirmAccountDeleteTitle": "Xác nhận xóa tài khoản",
"confirmAccountDeleteMessage": "Tài khoản này được liên kết với các ứng dụng ente khác, nếu bạn sử dụng bất kỳ ứng dụng nào.\n\nDữ liệu đã tải lên của bạn, trên tất cả các ứng dụng, sẽ bị lên lịch xóa và tài khoản của bạn sẽ bị xóa vĩnh viễn.",
"androidBiometricHint": "Xác định danh tính",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -78,14 +78,14 @@
"data": "数据",
"importCodes": "导入代码",
"importTypePlainText": "纯文本",
"importTypeEnteEncrypted": "ente 加密导出",
"importTypeEnteEncrypted": "Ente 加密导出",
"passwordForDecryptingExport": "用来解密导出的密码",
"passwordEmptyError": "密码不能为空",
"importFromApp": "从 {appName} 导入代码",
"importGoogleAuthGuide": "使用“转移帐户”选项将您的帐户从 Google 身份验证器导出到二维码。然后使用另一台设备扫描二维码。\n\n提示您可以使用笔记本电脑的网络摄像头拍摄二维码的照片。",
"importSelectJsonFile": "选择 JSON 文件",
"importSelectAppExport": "选择 {appName} 的导出文件",
"importEnteEncGuide": "选择从ente导出的加密JSON文件",
"importEnteEncGuide": "选择从 Ente 导出的 JSON 加密文件",
"importRaivoGuide": "使用 Raivo 设置中的“将 OTP 导出到 Zip 存档”选项。\n\n解压 zip 文件并导入 JSON 文件。",
"importBitwardenGuide": "使用 Bitwarden 工具中的“导出保管库”选项并导入未加密的 JSON 文件。",
"importAegisGuide": "在Aegis的设置中使用\"导出密码库\"选项。\n\n如果您的密码库已加密您需要输入密码才能解密密码库。",
@ -115,22 +115,22 @@
"copied": "已复制",
"pleaseTryAgain": "请重试",
"existingUser": "已注册用户",
"newUser": "刚来到ente",
"newUser": "初来 Ente",
"delete": "删除",
"enterYourPasswordHint": "输入您的密码",
"forgotPassword": "忘记密码",
"oops": "哎呀",
"suggestFeatures": "建议新功能",
"faq": "常见问题",
"faq_q_1": "ente Auth的安全程度如何?",
"faq_a_1": "您通过 ente 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。 我们的应用程序是开源的,我们的加密技术已经过外部审计。",
"faq_q_1": "Auth 的安全性如何?",
"faq_a_1": "您通过 Auth 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。我们的应用程序是开源的并且我们的加密技术已经过外部审计。",
"faq_q_2": "我可以在桌面上访问我的代码吗?",
"faq_a_2": "您可以在 web @auth.ente.io 上访问您的代码。",
"faq_q_3": "我如何删除代码?",
"faq_a_3": "您可以通过向左滑动该项目来删除该代码。",
"faq_q_4": "我该如何支持该项目?",
"faq_a_4": "您可以通过订阅我们的照片应用程序@ente.io来支持该项目的开发。",
"faq_q_5": "如何在 ente Auth 中启用 FaceID 锁定",
"faq_q_5": "我如何启用 Auth 中的面容 ID 锁",
"faq_a_5": "您可以在“设置”→“安全”→“锁屏”下启用 FaceID 锁定。",
"somethingWentWrongMessage": "出了点问题,请重试",
"leaveFamily": "离开家庭",
@ -143,7 +143,7 @@
"verifyEmail": "验证电子邮件",
"enterCodeHint": "从你的身份验证器应用中\n输入6位数字代码",
"lostDeviceTitle": "丢失了设备吗?",
"twoFactorAuthTitle": "双因素认证",
"twoFactorAuthTitle": "双认证",
"passkeyAuthTitle": "通行密钥认证",
"verifyPasskey": "验证通行密钥",
"recoverAccount": "恢复账户",
@ -199,6 +199,10 @@
"recoveryKeySaveDescription": "我们不会存储此密钥请将此24个单词密钥保存在一个安全的地方。",
"doThisLater": "稍后再做",
"saveKey": "保存密钥",
"save": "保存",
"send": "发送",
"saveOrSendDescription": "您想将其保存到您的内置存储(默认情况下为“下载”文件夹)还是将其发送到其他应用程序?",
"saveOnlyDescription": "您想将其保存到您的内置存储中(默认情况下为“下载”文件夹)吗?",
"back": "返回",
"createAccount": "创建账户",
"passwordStrength": "密码强度: {passwordStrengthValue}",
@ -321,7 +325,7 @@
"emailChangedTo": "电子邮件已更改为 {newEmail}",
"authenticationFailedPleaseTryAgain": "认证失败,请重试",
"authenticationSuccessful": "认证成功!",
"twofactorAuthenticationSuccessfullyReset": "双因素身份验证已成功重置",
"twofactorAuthenticationSuccessfullyReset": "双重认证已成功重置",
"incorrectRecoveryKey": "恢复密钥不正确",
"theRecoveryKeyYouEnteredIsIncorrect": "您输入的恢复密钥不正确",
"enterPassword": "输入密码",
@ -346,7 +350,7 @@
"deleteCodeAuthMessage": "删除代码需要身份验证",
"showQRAuthMessage": "显示QR码需要身份验证",
"confirmAccountDeleteTitle": "确认删除账户",
"confirmAccountDeleteMessage": "该账户已链接到其他ente旗下的应用程序如果您使用任何相关的应用程序。\n\n您在所有ente旗下应用程序中上传的数据都将被安排删除,并且您的账户将被永久删除。",
"confirmAccountDeleteMessage": "如果您使用其他 Ente 应用程序,该账户将会与其他应用程序链接。\n\n在所有 Ente 应用程序中,您上传的数据将被安排用于删除,并且您的账户将被永久删除。",
"androidBiometricHint": "验证身份",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -407,6 +411,7 @@
"doNotSignOut": "不要退登",
"hearUsWhereTitle": "您是如何知道Ente的 (可选的)",
"hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!",
"recoveryKeySaved": "恢复密钥已保存在下载文件夹中!",
"waitingForBrowserRequest": "正在等待浏览器请求...",
"waitingForVerification": "等待验证...",
"passkey": "通行密钥",

View file

@ -51,6 +51,7 @@ class _HomePageState extends State<HomePage> {
final scaffoldKey = GlobalKey<ScaffoldState>();
final TextEditingController _textController = TextEditingController();
final FocusNode searchInputFocusNode = FocusNode();
bool _showSearchBox = false;
String _searchText = "";
List<Code> _codes = [];
@ -80,6 +81,17 @@ class _HomePageState extends State<HomePage> {
setState(() {});
});
_showSearchBox = PreferenceService.instance.shouldAutoFocusOnSearchBar();
if (_showSearchBox) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
// https://github.com/flutter/flutter/issues/20706#issuecomment-646328652
FocusScope.of(context).unfocus();
Timer(const Duration(milliseconds: 1), () {
FocusScope.of(context).requestFocus(searchInputFocusNode);
});
},
);
}
}
void _loadCodes() {
@ -93,12 +105,22 @@ class _HomePageState extends State<HomePage> {
void _applyFilteringAndRefresh() {
if (_searchText.isNotEmpty && _showSearchBox) {
final String val = _searchText.toLowerCase();
_filteredCodes = _codes
.where(
(element) => (element.account.toLowerCase().contains(val) ||
element.issuer.toLowerCase().contains(val)),
)
.toList();
// Prioritize issuer match above account for better UX while searching
// for a specific TOTP for email providers. Searching for "emailProvider" like (gmail, proton) should
// show the email provider first instead of other accounts where protonmail
// is the account name.
final List<Code> issuerMatch = [];
final List<Code> accountMatch = [];
for (final Code code in _codes) {
if (code.issuer.toLowerCase().contains(val)) {
issuerMatch.add(code);
} else if (code.account.toLowerCase().contains(val)) {
accountMatch.add(code);
}
}
_filteredCodes = issuerMatch;
_filteredCodes.addAll(accountMatch);
} else {
_filteredCodes = _codes;
}
@ -182,6 +204,7 @@ class _HomePageState extends State<HomePage> {
title: !_showSearchBox
? const Text('Ente Auth')
: TextField(
focusNode: searchInputFocusNode,
autofocus: _searchText.isEmpty,
controller: _textController,
onChanged: (val) {

View file

@ -13,7 +13,6 @@
#include <gtk/gtk_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h>
#include <smart_auth/smart_auth_plugin.h>
#include <sodium_libs/sodium_libs_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <tray_manager/tray_manager_plugin.h>
@ -42,9 +41,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) sentry_flutter_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin");
sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar);
g_autoptr(FlPluginRegistrar) smart_auth_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
g_autoptr(FlPluginRegistrar) sodium_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin");
sodium_libs_plugin_register_with_registrar(sodium_libs_registrar);

View file

@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
gtk
screen_retriever
sentry_flutter
smart_auth
sodium_libs
sqlite3_flutter_libs
tray_manager

View file

@ -25,3 +25,4 @@ startup_notify: false
# - libcurl.so.4
include:
- libffi.so.7
- libtiff.so.5

View file

@ -9,7 +9,7 @@ url: https://github.com/ente-io/ente
display_name: Auth
dependencies:
requires:
- libsqlite3x
- webkit2gtk-4.0
- libsodium

View file

@ -20,7 +20,6 @@ import screen_retriever
import sentry_flutter
import share_plus
import shared_preferences_foundation
import smart_auth
import sodium_libs
import sqflite
import sqlite3_flutter_libs
@ -44,7 +43,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))

View file

@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.5.0"
async:
dependency: transitive
description:
@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e
sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb
url: "https://pub.dev"
source: hosted
version: "8.9.1"
version: "8.9.2"
characters:
dependency: transitive
description:
@ -318,10 +318,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "0978e9a3e45305a80a7210dbeaf79d6ee8bee33f70c8e542dc654c952070217f"
sha256: "639179e1cc0957779e10dd5b786ce180c477c4c0aca5aaba5d1700fa2e834801"
url: "https://pub.dev"
source: hosted
version: "5.4.2+1"
version: "5.4.3"
dotted_border:
dependency: "direct main"
description:
@ -586,10 +586,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da
sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f"
url: "https://pub.dev"
source: hosted
version: "2.0.17"
version: "2.0.19"
flutter_secure_storage:
dependency: "direct main"
description:
@ -1053,18 +1053,18 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b
sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668"
sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.4"
path_provider_foundation:
dependency: transitive
description:
@ -1133,10 +1133,10 @@ packages:
dependency: "direct main"
description:
name: pointycastle
sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29"
sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333"
url: "https://pub.dev"
source: hosted
version: "3.7.4"
version: "3.8.0"
pool:
dependency: transitive
description:
@ -1221,18 +1221,18 @@ packages:
dependency: "direct main"
description:
name: sentry
sha256: a460aa48568d47140dd0557410b624d344ffb8c05555107ac65035c1097cf1ad
sha256: fe99a06970b909a491b7f89d54c9b5119772e3a48a400308a6e129625b333f5b
url: "https://pub.dev"
source: hosted
version: "7.18.0"
version: "7.19.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: "3d0d1d4e0e407d276ae8128d123263ccbc37e988bae906765efd6f37d544f4c6"
sha256: fc013d4a753447320f62989b1871fdc1f20c77befcc8be3e38774dd7402e7a62
url: "https://pub.dev"
source: hosted
version: "7.18.0"
version: "7.19.0"
share_plus:
dependency: "direct main"
description:
@ -1253,18 +1253,18 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02"
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06"
sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.2.2"
shared_preferences_foundation:
dependency: transitive
description:
@ -1387,10 +1387,10 @@ packages:
description:
path: sqflite
ref: HEAD
resolved-ref: "075b3e2f81e691a19a500e7ff6db2953de7f83a9"
resolved-ref: f281785e12e8b1abf2f9d41a587fc83d810724cf
url: "https://github.com/tekartik/sqflite"
source: git
version: "2.3.2"
version: "2.3.3"
sqflite_common:
dependency: transitive
description:
@ -1411,10 +1411,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3
sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9"
sha256: "1abbeb84bf2b1a10e5e1138c913123c8aa9d83cd64e5f9a0dd847b3c83063202"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.4.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
@ -1515,10 +1515,10 @@ packages:
dependency: "direct main"
description:
name: tray_manager
sha256: "4ab709d70a4374af172f8c39e018db33a4271265549c6fc9d269a65e5f4b0225"
sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29
url: "https://pub.dev"
source: hosted
version: "0.2.1"
version: "0.2.2"
tuple:
dependency: "direct main"
description:
@ -1547,18 +1547,18 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e"
sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e"
url: "https://pub.dev"
source: hosted
version: "6.2.5"
version: "6.2.6"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745
sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.3.1"
url_launcher_ios:
dependency: transitive
description:
@ -1611,10 +1611,10 @@ packages:
dependency: "direct main"
description:
name: uuid
sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8
sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8"
url: "https://pub.dev"
source: hosted
version: "4.3.3"
version: "4.4.0"
vector_graphics:
dependency: transitive
description:
@ -1675,18 +1675,18 @@ packages:
dependency: transitive
description:
name: web_socket_channel
sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.4.5"
win32:
dependency: "direct main"
description:
name: win32
sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480"
sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a"
url: "https://pub.dev"
source: hosted
version: "5.3.0"
version: "5.4.0"
win32_registry:
dependency: transitive
description:

View file

@ -1,6 +1,6 @@
name: ente_auth
description: ente two-factor authenticator
version: 2.0.50+250
version: 2.0.55+255
publish_to: none
environment:

View file

@ -16,7 +16,6 @@
#include <screen_retriever/screen_retriever_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <smart_auth/smart_auth_plugin.h>
#include <sodium_libs/sodium_libs_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <tray_manager/tray_manager_plugin.h>
@ -44,8 +43,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("SentryFlutterPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
SmartAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SmartAuthPlugin"));
SodiumLibsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SodiumLibsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(

View file

@ -13,7 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever
sentry_flutter
share_plus
smart_auth
sodium_libs
sqlite3_flutter_libs
tray_manager

View file

@ -7,11 +7,6 @@ module.exports = {
// "plugin:@typescript-eslint/strict-type-checked",
// "plugin:@typescript-eslint/stylistic-type-checked",
],
/* Temporarily disable some rules
Enhancement: Remove me */
rules: {
"no-unused-vars": "off",
},
/* Temporarily add a global
Enhancement: Remove me */
globals: {

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
desktop/build/icon.icns Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,5 +1,9 @@
# Dependencies
- [Electron](#electron)
- [Dev dependencies](#dev)
- [Functionality](#functionality)
## Electron
[Electron](https://www.electronjs.org) is a cross-platform (Linux, Windows,
@ -61,34 +65,34 @@ Electron process. This allows us to directly use the output produced by
### Others
* [any-shell-escape](https://github.com/boazy/any-shell-escape) is for
escaping shell commands before we execute them (e.g. say when invoking the
embedded ffmpeg CLI).
- [any-shell-escape](https://github.com/boazy/any-shell-escape) is for
escaping shell commands before we execute them (e.g. say when invoking the
embedded ffmpeg CLI).
* [auto-launch](https://github.com/Teamwork/node-auto-launch) is for
automatically starting our app on login, if the user so wishes.
- [auto-launch](https://github.com/Teamwork/node-auto-launch) is for
automatically starting our app on login, if the user so wishes.
* [electron-store](https://github.com/sindresorhus/electron-store) is used for
persisting user preferences and other arbitrary data.
- [electron-store](https://github.com/sindresorhus/electron-store) is used for
persisting user preferences and other arbitrary data.
## Dev
See [web/docs/dependencies#DX](../../web/docs/dependencies.md#dev) for the
See [web/docs/dependencies#dev](../../web/docs/dependencies.md#dev) for the
general development experience related dependencies like TypeScript etc, which
are similar to that in the web code.
Some extra ones specific to the code here are:
* [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
parallel tasks when we do `yarn dev`.
- [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
parallel tasks when we do `yarn dev`.
* [shx](https://github.com/shelljs/shx) for providing a portable way to use Unix
commands in our `package.json` scripts. This allows us to use the same
commands (like `ln`) across different platforms like Linux and Windows.
- [shx](https://github.com/shelljs/shx) for providing a portable way to use
Unix commands in our `package.json` scripts. This allows us to use the same
commands (like `ln`) across different platforms like Linux and Windows.
## Functionality
### Conversion
### Format conversion
The main tool we use is for arbitrary conversions is FFMPEG. To bundle a
(platform specific) static binary of ffmpeg with our app, we use
@ -104,20 +108,23 @@ resources (`build`) folder. This is used for thumbnail generation on Linux.
On macOS, we use the `sips` CLI tool for conversion, but that is already
available on the host machine, and is not bundled with our app.
### AI/ML
[onnxruntime-node](https://github.com/Microsoft/onnxruntime) is used as the
AI/ML runtime. It powers both natural language searches (using CLIP) and face
detection (using YOLO).
[jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) is used for decoding JPEG
data into raw RGB bytes before passing it to ONNX.
html-entities is used by the bundled clip-bpe-ts tokenizer for CLIP.
### Watch Folders
[chokidar](https://github.com/paulmillr/chokidar) is used as a file system
watcher for the watch folders functionality.
### AI/ML
* [onnxruntime-node](https://github.com/Microsoft/onnxruntime)
* html-entities is used by the bundled clip-bpe-ts.
* GGML binaries are bundled
* We also use [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) for
conversion of all images to JPEG before processing.
## ZIP
### ZIP
[node-stream-zip](https://github.com/antelle/node-stream-zip) is used for
reading of large ZIP files (e.g. during imports of Google Takeout ZIPs).

View file

@ -1,5 +1,15 @@
appId: io.ente.bhari-frame
artifactName: ${productName}-${version}-${arch}.${ext}
files:
- app/**/*
- out
extraFiles:
- from: build
to: resources
win:
target:
- target: nsis
arch: [x64, arm64]
nsis:
deleteAppDataOnUninstall: true
linux:
@ -19,11 +29,4 @@ mac:
arch: [universal]
category: public.app-category.photography
hardenedRuntime: true
x64ArchFiles: Contents/Resources/ggmlclip-mac
afterSign: electron-builder-notarize
extraFiles:
- from: build
to: resources
files:
- app/**/*
- out

View file

@ -11,7 +11,7 @@
"build-main:quick": "tsc && electron-builder --dir --config.compression=store --config.mac.identity=null",
"build-renderer": "cd ../web && yarn install && yarn build:photos && cd ../desktop && shx rm -f out && shx ln -sf ../web/apps/photos/out out",
"build:quick": "yarn build-renderer && yarn build-main:quick",
"dev": "concurrently --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"",
"dev": "concurrently --kill-others --success first --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"",
"dev-main": "tsc && electron app/main.js",
"dev-renderer": "cd ../web && yarn install && yarn dev:photos",
"postinstall": "electron-builder install-app-deps",

View file

@ -1,17 +0,0 @@
import { logError } from "../main/log";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";
import { watchStore } from "../stores/watch.store";
export const clearElectronStore = () => {
try {
uploadStatusStore.clear();
keysStore.clear();
safeStorageStore.clear();
watchStore.clear();
} catch (e) {
logError(e, "error while clearing electron store");
throw e;
}
};

View file

@ -1,28 +0,0 @@
import { safeStorage } from "electron/main";
import { logError } from "../main/log";
import { safeStorageStore } from "../stores/safeStorage.store";
export async function setEncryptionKey(encryptionKey: string) {
try {
const encryptedKey: Buffer =
await safeStorage.encryptString(encryptionKey);
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
safeStorageStore.set("encryptionKey", b64EncryptedKey);
} catch (e) {
logError(e, "setEncryptionKey failed");
throw e;
}
}
export async function getEncryptionKey(): Promise<string> {
try {
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
if (b64EncryptedKey) {
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
return await safeStorage.decryptString(keyBuffer);
}
} catch (e) {
logError(e, "getEncryptionKey failed");
throw e;
}
}

View file

@ -1,41 +0,0 @@
import { getElectronFile } from "../services/fs";
import {
getElectronFilesFromGoogleZip,
getSavedFilePaths,
} from "../services/upload";
import { uploadStatusStore } from "../stores/upload.store";
import { ElectronFile, FILE_PATH_TYPE } from "../types/ipc";
export const getPendingUploads = async () => {
const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
const collectionName = uploadStatusStore.get("collectionName");
let files: ElectronFile[] = [];
let type: FILE_PATH_TYPE;
if (zipPaths.length) {
type = FILE_PATH_TYPE.ZIPS;
for (const zipPath of zipPaths) {
files = [
...files,
...(await getElectronFilesFromGoogleZip(zipPath)),
];
}
const pendingFilePaths = new Set(filePaths);
files = files.filter((file) => pendingFilePaths.has(file.path));
} else if (filePaths.length) {
type = FILE_PATH_TYPE.FILES;
files = await Promise.all(filePaths.map(getElectronFile));
}
return {
files,
collectionName,
type,
};
};
export {
getElectronFilesFromGoogleZip,
setToUploadCollection,
setToUploadFiles,
} from "../services/upload";

View file

@ -1,26 +0,0 @@
/**
* [Note: Custom errors across Electron/Renderer boundary]
*
* We need to use the `message` field to disambiguate between errors thrown by
* the main process when invoked from the renderer process. This is because:
*
* > Errors thrown throw `handle` in the main process are not transparent as
* > they are serialized and only the `message` property from the original error
* > is provided to the renderer process.
* >
* > - https://www.electronjs.org/docs/latest/tutorial/ipc
* >
* > Ref: https://github.com/electron/electron/issues/24427
*/
export const CustomErrors = {
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
"Windows native image processing is not supported",
INVALID_OS: (os: string) => `Invalid OS - ${os}`,
WAIT_TIME_EXCEEDED: "Wait time exceeded",
UNSUPPORTED_PLATFORM: (platform: string, arch: string) =>
`Unsupported platform - ${platform} ${arch}`,
MODEL_DOWNLOAD_PENDING:
"Model download pending, skipping clip search request",
INVALID_FILE_PATH: "Invalid file path",
INVALID_CLIP_MODEL: (model: string) => `Invalid Clip model - ${model}`,
};

View file

@ -8,59 +8,62 @@
*
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
*/
import { app, BrowserWindow, Menu } from "electron/main";
import { nativeImage } from "electron";
import { app, BrowserWindow, Menu, Tray } from "electron/main";
import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
addAllowOriginHeader,
createWindow,
handleDockIconHideOnAutoLaunch,
handleDownloads,
handleExternalLinks,
logStartupBanner,
setupMacWindowOnDockIconClick,
setupTrayItem,
} from "./main/init";
import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
import log, { initLogging } from "./main/log";
import { createApplicationMenu } from "./main/menu";
import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
import { setupAutoUpdater } from "./main/services/app-update";
import autoLauncher from "./main/services/autoLauncher";
import { initWatcher } from "./main/services/chokidar";
import { userPreferences } from "./main/stores/user-preferences";
import { isDev } from "./main/util";
import { setupAutoUpdater } from "./services/appUpdater";
import { initWatcher } from "./services/chokidar";
let appIsQuitting = false;
let updateIsAvailable = false;
export const isAppQuitting = (): boolean => {
return appIsQuitting;
};
export const setIsAppQuitting = (value: boolean): void => {
appIsQuitting = value;
};
export const isUpdateAvailable = (): boolean => {
return updateIsAvailable;
};
export const setIsUpdateAvailable = (value: boolean): void => {
updateIsAvailable = value;
};
/**
* The URL where the renderer HTML is being served from.
*/
export const rendererURL = "next://app";
export const rendererURL = "ente://app";
/**
* We want to hide our window instead of closing it when the user presses the
* cross button on the window.
*
* > This is because there is 1. a perceptible initial window creation time for
* > our app, and 2. because the long running processes like export and watch
* > folders are tied to the lifetime of the window and otherwise won't run in
* > the background.
*
* Intercepting the window close event and using that to instead hide it is
* easy, however that prevents the actual app quit to stop working (since the
* window never gets closed).
*
* So to achieve our original goal (hide window instead of closing) without
* disabling expected app quits, we keep a flag, and we turn it on when we're
* part of the quit sequence. When this flag is on, we bypass the code that
* prevents the window from being closed.
*/
let shouldAllowWindowClose = false;
export const allowWindowClose = (): void => {
shouldAllowWindowClose = true;
};
/**
* next-electron-server allows up to directly use the output of `next build` in
* production mode and `next dev` in development mode, whilst keeping the rest
* of our code the same.
*
* It uses protocol handlers to serve files from the "next://app" protocol
* It uses protocol handlers to serve files from the "ente://" protocol.
*
* - In development this is proxied to http://localhost:3000
* - In production it serves files from the `/out` directory
@ -68,33 +71,145 @@ export const rendererURL = "next://app";
* For more details, see this comparison:
* https://github.com/HaNdTriX/next-electron-server/issues/5
*/
const setupRendererServer = () => {
serveNextAt(rendererURL);
};
const setupRendererServer = () => serveNextAt(rendererURL);
function enableSharedArrayBufferSupport() {
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");
}
/**
* Log a standard startup banner.
*
* This helps us identify app starts and other environment details in the logs.
*/
const logStartupBanner = () => {
const version = isDev ? "dev" : app.getVersion();
log.info(`Starting ente-photos-desktop ${version}`);
const platform = process.platform;
const osRelease = os.release();
const systemVersion = process.getSystemVersion();
log.info("Running on", { platform, osRelease, systemVersion });
};
/**
* [Note: Increased disk cache for the desktop app]
*
* Set the "disk-cache-size" command line flag to ask the Chromium process to
* use a larger size for the caches that it keeps on disk. This allows us to use
* the same web-native caching mechanism on both the web and the desktop app,
* just ask the embedded Chromium to be a bit more generous in disk usage when
* the web based caching mechanisms on both the web and the desktop app, just
* ask the embedded Chromium to be a bit more generous in disk usage when
* running as the desktop app.
*
* The size we provide is in bytes. We set it to a large value, 5 GB (5 * 1024 *
* 1024 * 1024 = 5368709120)
* The size we provide is in bytes.
* https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize
*
* Note that increasing the disk cache size does not guarantee that Chromium
* will respect in verbatim, it uses its own heuristics atop this hint.
* https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693
*
* See also: [Note: Caching files].
*/
const increaseDiskCache = () => {
app.commandLine.appendSwitch("disk-cache-size", "5368709120");
const increaseDiskCache = () =>
app.commandLine.appendSwitch(
"disk-cache-size",
`${5 * 1024 * 1024 * 1024}`, // 5 GB
);
/**
* Create an return the {@link BrowserWindow} that will form our app's UI.
*
* This window will show the HTML served from {@link rendererURL}.
*/
const createMainWindow = async () => {
// Create the main window. This'll show our web content.
const window = new BrowserWindow({
webPreferences: {
preload: path.join(app.getAppPath(), "preload.js"),
sandbox: true,
},
// The color to show in the window until the web content gets loaded.
// See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
backgroundColor: "black",
// We'll show it conditionally depending on `wasAutoLaunched` later.
show: false,
});
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (wasAutoLaunched) {
// Don't automatically show the app's window if we were auto-launched.
// On macOS, also hide the dock icon on macOS.
if (process.platform == "darwin") app.dock.hide();
} else {
// Show our window (maximizing it) otherwise.
window.maximize();
}
window.loadURL(rendererURL);
// Open the DevTools automatically when running in dev mode
if (isDev) window.webContents.openDevTools();
window.webContents.on("render-process-gone", (_, details) => {
log.error(`render-process-gone: ${details}`);
window.webContents.reload();
});
window.webContents.on("unresponsive", () => {
log.error(
"Main window's webContents are unresponsive, will restart the renderer process",
);
window.webContents.forcefullyCrashRenderer();
});
window.on("close", (event) => {
if (!shouldAllowWindowClose) {
event.preventDefault();
window.hide();
}
return false;
});
window.on("hide", () => {
// On macOS, when hiding the window also hide the app's icon in the dock
// if the user has selected the Settings > Hide dock icon checkbox.
if (process.platform == "darwin" && userPreferences.get("hideDockIcon"))
app.dock.hide();
});
window.on("show", () => {
if (process.platform == "darwin") app.dock.show();
});
// Let ipcRenderer know when mainWindow is in the foreground so that it can
// in turn inform the renderer process.
window.on("focus", () => window.webContents.send("mainWindowFocus"));
return window;
};
/**
* Add an icon for our app in the system tray.
*
* For example, these are the small icons that appear on the top right of the
* screen in the main menu bar on macOS.
*/
const setupTrayItem = (mainWindow: BrowserWindow) => {
// There are a total of 6 files corresponding to this tray icon.
//
// On macOS, use template images (filename needs to end with "Template.ext")
// https://www.electronjs.org/docs/latest/api/native-image#template-image-macos
//
// And for each (template or otherwise), there are 3 "retina" variants
// https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image
const iconName =
process.platform == "darwin"
? "taskbar-icon-Template.png"
: "taskbar-icon.png";
const trayImgPath = path.join(
isDev ? "build" : process.resourcesPath,
iconName,
);
const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon);
tray.setToolTip("Ente Photos");
tray.setContextMenu(createTrayContextMenu(mainWindow));
};
/**
@ -126,13 +241,6 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
}
};
function setupAppEventEmitter(mainWindow: BrowserWindow) {
// fire event when mainWindow is in foreground
mainWindow.on("focus", () => {
mainWindow.webContents.send("app-in-foreground");
});
}
const main = () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
@ -140,21 +248,18 @@ const main = () => {
return;
}
let mainWindow: BrowserWindow;
let mainWindow: BrowserWindow | undefined;
initLogging();
setupRendererServer();
handleDockIconHideOnAutoLaunch();
logStartupBanner();
increaseDiskCache();
enableSharedArrayBufferSupport();
app.on("second-instance", () => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
mainWindow.show();
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
@ -163,11 +268,9 @@ const main = () => {
//
// Note that some Electron APIs can only be used after this event occurs.
app.on("ready", async () => {
logStartupBanner();
mainWindow = await createWindow();
mainWindow = await createMainWindow();
const watcher = initWatcher(mainWindow);
setupTrayItem(mainWindow);
setupMacWindowOnDockIconClick();
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
attachIPCHandlers();
attachFSWatchIPCHandlers(watcher);
@ -175,18 +278,21 @@ const main = () => {
handleDownloads(mainWindow);
handleExternalLinks(mainWindow);
addAllowOriginHeader(mainWindow);
setupAppEventEmitter(mainWindow);
try {
deleteLegacyDiskCacheDirIfExists();
} catch (e) {
// Log but otherwise ignore errors during non-critical startup
// actions
// actions.
log.error("Ignoring startup error", e);
}
});
app.on("before-quit", () => setIsAppQuitting(true));
// This is a macOS only event. Show our window when the user activates the
// app, e.g. by clicking on its dock icon.
app.on("activate", () => mainWindow?.show());
app.on("before-quit", allowWindowClose);
};
main();

View file

@ -1,8 +1,8 @@
import { dialog } from "electron/main";
import path from "node:path";
import { getDirFilePaths, getElectronFile } from "../services/fs";
import { getElectronFilesFromGoogleZip } from "../services/upload";
import type { ElectronFile } from "../types/ipc";
import { getDirFilePaths, getElectronFile } from "./services/fs";
import { getElectronFilesFromGoogleZip } from "./services/upload";
export const selectDirectory = async () => {
const result = await dialog.showOpenDialog({

View file

@ -3,11 +3,20 @@
*/
import { createWriteStream, existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
export const fsExists = (path: string) => existsSync(path);
export const fsRename = (oldPath: string, newPath: string) =>
fs.rename(oldPath, newPath);
export const fsMkdirIfNeeded = (dirPath: string) =>
fs.mkdir(dirPath, { recursive: true });
export const fsRmdir = (path: string) => fs.rmdir(path);
export const fsRm = (path: string) => fs.rm(path);
/**
* Write a (web) ReadableStream to a file at the given {@link filePath}.
*
@ -73,9 +82,6 @@ const writeNodeStream = async (
/* TODO: Audit below this */
export const checkExistsAndCreateDir = (dirPath: string) =>
fs.mkdir(dirPath, { recursive: true });
export const saveStreamToDisk = writeStream;
export const saveFileToDisk = (path: string, contents: string) =>
@ -84,50 +90,8 @@ export const saveFileToDisk = (path: string, contents: string) =>
export const readTextFile = async (filePath: string) =>
fs.readFile(filePath, "utf-8");
export const moveFile = async (sourcePath: string, destinationPath: string) => {
if (!existsSync(sourcePath)) {
throw new Error("File does not exist");
}
if (existsSync(destinationPath)) {
throw new Error("Destination file already exists");
}
// check if destination folder exists
const destinationFolder = path.dirname(destinationPath);
await fs.mkdir(destinationFolder, { recursive: true });
await fs.rename(sourcePath, destinationPath);
};
export const isFolder = async (dirPath: string) => {
if (!existsSync(dirPath)) return false;
const stats = await fs.stat(dirPath);
return stats.isDirectory();
};
export const deleteFolder = async (folderPath: string) => {
// Ensure it is folder
if (!isFolder(folderPath)) return;
// Ensure folder is empty
const files = await fs.readdir(folderPath);
if (files.length > 0) throw new Error("Folder is not empty");
// rm -rf it
await fs.rmdir(folderPath);
};
export const rename = async (oldPath: string, newPath: string) => {
if (!existsSync(oldPath)) throw new Error("Path does not exist");
await fs.rename(oldPath, newPath);
};
export const deleteFile = async (filePath: string) => {
// Ensure it exists
if (!existsSync(filePath)) return;
// And is a file
const stat = await fs.stat(filePath);
if (!stat.isFile()) throw new Error("Path is not a file");
// rm it
return fs.rm(filePath);
};

View file

@ -1,97 +1,7 @@
import { app, BrowserWindow, nativeImage, Tray } from "electron";
import { BrowserWindow, app, shell } from "electron";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { isAppQuitting, rendererURL } from "../main";
import autoLauncher from "../services/autoLauncher";
import { getHideDockIconPreference } from "../services/userPreference";
import { isPlatform } from "../utils/common/platform";
import log from "./log";
import { createTrayContextMenu } from "./menu";
import { isDev } from "./util";
/**
* Create an return the {@link BrowserWindow} that will form our app's UI.
*
* This window will show the HTML served from {@link rendererURL}.
*/
export const createWindow = async () => {
// Create the main window. This'll show our web content.
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(app.getAppPath(), "preload.js"),
},
// The color to show in the window until the web content gets loaded.
// See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
backgroundColor: "black",
// We'll show it conditionally depending on `wasAutoLaunched` later.
show: false,
});
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (wasAutoLaunched) {
// Keep the macOS dock icon hidden if we were auto launched.
if (process.platform == "darwin") app.dock.hide();
} else {
// Show our window (maximizing it) if this is not an auto-launch on
// login.
mainWindow.maximize();
}
mainWindow.loadURL(rendererURL);
// Open the DevTools automatically when running in dev mode
if (isDev) mainWindow.webContents.openDevTools();
mainWindow.webContents.on("render-process-gone", (_, details) => {
log.error(`render-process-gone: ${details}`);
mainWindow.webContents.reload();
});
mainWindow.webContents.on("unresponsive", () => {
log.error("webContents unresponsive");
mainWindow.webContents.forcefullyCrashRenderer();
});
mainWindow.on("close", function (event) {
if (!isAppQuitting()) {
event.preventDefault();
mainWindow.hide();
}
return false;
});
mainWindow.on("hide", () => {
// On macOS, also hide the app's icon in the dock if the user has
// selected the Settings > Hide dock icon checkbox.
const shouldHideDockIcon = getHideDockIconPreference();
if (process.platform == "darwin" && shouldHideDockIcon) {
app.dock.hide();
}
});
mainWindow.on("show", () => {
if (process.platform == "darwin") app.dock.show();
});
return mainWindow;
};
export async function handleUpdates(mainWindow: BrowserWindow) {}
export const setupTrayItem = (mainWindow: BrowserWindow) => {
const iconName = isPlatform("mac")
? "taskbar-icon-Template.png"
: "taskbar-icon.png";
const trayImgPath = path.join(
isDev ? "build" : process.resourcesPath,
iconName,
);
const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon);
tray.setToolTip("ente");
tray.setContextMenu(createTrayContextMenu(mainWindow));
};
import { rendererURL } from "../main";
export function handleDownloads(mainWindow: BrowserWindow) {
mainWindow.webContents.session.on("will-download", (_, item) => {
@ -104,7 +14,7 @@ export function handleDownloads(mainWindow: BrowserWindow) {
export function handleExternalLinks(mainWindow: BrowserWindow) {
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(rendererURL)) {
require("electron").shell.openExternal(url);
shell.openExternal(url);
return { action: "deny" };
} else {
return { action: "allow" };
@ -132,33 +42,6 @@ export function getUniqueSavePath(filename: string, directory: string): string {
return uniqueFileSavePath;
}
export function setupMacWindowOnDockIconClick() {
app.on("activate", function () {
const windows = BrowserWindow.getAllWindows();
// we allow only one window
windows[0].show();
});
}
export async function handleDockIconHideOnAutoLaunch() {
const shouldHideDockIcon = getHideDockIconPreference();
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (isPlatform("mac") && shouldHideDockIcon && wasAutoLaunched) {
app.dock.hide();
}
}
export function logStartupBanner() {
const version = isDev ? "dev" : app.getVersion();
log.info(`Hello from ente-photos-desktop ${version}`);
const platform = process.platform;
const osRelease = os.release();
const systemVersion = process.getSystemVersion();
log.info("Running on", { platform, osRelease, systemVersion });
}
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
const headers: Record<string, string[]> = {};
for (const key of Object.keys(responseHeaders)) {

View file

@ -10,43 +10,7 @@
import type { FSWatcher } from "chokidar";
import { ipcMain } from "electron/main";
import { clearElectronStore } from "../api/electronStore";
import { getEncryptionKey, setEncryptionKey } from "../api/safeStorage";
import {
getElectronFilesFromGoogleZip,
getPendingUploads,
setToUploadCollection,
setToUploadFiles,
} from "../api/upload";
import {
appVersion,
muteUpdateNotification,
skipAppUpdate,
updateAndRestart,
} from "../services/appUpdater";
import {
computeImageEmbedding,
computeTextEmbedding,
} from "../services/clipService";
import { runFFmpegCmd } from "../services/ffmpeg";
import { getDirFiles } from "../services/fs";
import {
convertToJPEG,
generateImageThumbnail,
} from "../services/imageProcessor";
import {
addWatchMapping,
getWatchMappings,
removeWatchMapping,
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
} from "../services/watch";
import type {
ElectronFile,
FILE_PATH_TYPE,
Model,
WatchMapping,
} from "../types/ipc";
import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
import {
selectDirectory,
showUploadDirsDialog,
@ -54,18 +18,49 @@ import {
showUploadZipDialog,
} from "./dialogs";
import {
checkExistsAndCreateDir,
deleteFile,
deleteFolder,
fsExists,
fsMkdirIfNeeded,
fsRename,
fsRm,
fsRmdir,
isFolder,
moveFile,
readTextFile,
rename,
saveFileToDisk,
saveStreamToDisk,
} from "./fs";
import { logToDisk } from "./log";
import {
appVersion,
skipAppUpdate,
updateAndRestart,
updateOnNextRestart,
} from "./services/app-update";
import { runFFmpegCmd } from "./services/ffmpeg";
import { getDirFiles } from "./services/fs";
import {
convertToJPEG,
generateImageThumbnail,
} from "./services/imageProcessor";
import { clipImageEmbedding, clipTextEmbedding } from "./services/ml-clip";
import { detectFaces, faceEmbedding } from "./services/ml-face";
import {
clearStores,
encryptionKey,
saveEncryptionKey,
} from "./services/store";
import {
getElectronFilesFromGoogleZip,
getPendingUploads,
setToUploadCollection,
setToUploadFiles,
} from "./services/upload";
import {
addWatchMapping,
getWatchMappings,
removeWatchMapping,
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
} from "./services/watch";
import { openDirectory, openLogDirectory } from "./util";
/**
@ -91,35 +86,33 @@ export const attachIPCHandlers = () => {
// - General
ipcMain.handle("appVersion", (_) => appVersion());
ipcMain.handle("appVersion", () => appVersion());
ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
ipcMain.handle("openLogDirectory", (_) => openLogDirectory());
ipcMain.handle("openLogDirectory", () => openLogDirectory());
// See [Note: Catching exception during .send/.on]
ipcMain.on("logToDisk", (_, message) => logToDisk(message));
ipcMain.on("clear-electron-store", (_) => {
clearElectronStore();
});
ipcMain.on("clearStores", () => clearStores());
ipcMain.handle("setEncryptionKey", (_, encryptionKey) =>
setEncryptionKey(encryptionKey),
ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
saveEncryptionKey(encryptionKey),
);
ipcMain.handle("getEncryptionKey", (_) => getEncryptionKey());
ipcMain.handle("encryptionKey", () => encryptionKey());
// - App update
ipcMain.on("update-and-restart", (_) => updateAndRestart());
ipcMain.on("updateAndRestart", () => updateAndRestart());
ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version));
ipcMain.on("mute-update-notification", (_, version) =>
muteUpdateNotification(version),
ipcMain.on("updateOnNextRestart", (_, version) =>
updateOnNextRestart(version),
);
ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
// - Conversion
ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
@ -145,65 +138,65 @@ export const attachIPCHandlers = () => {
// - ML
ipcMain.handle(
"computeImageEmbedding",
(_, model: Model, imageData: Uint8Array) =>
computeImageEmbedding(model, imageData),
ipcMain.handle("clipImageEmbedding", (_, jpegImageData: Uint8Array) =>
clipImageEmbedding(jpegImageData),
);
ipcMain.handle("computeTextEmbedding", (_, model: Model, text: string) =>
computeTextEmbedding(model, text),
ipcMain.handle("clipTextEmbedding", (_, text: string) =>
clipTextEmbedding(text),
);
ipcMain.handle("detectFaces", (_, input: Float32Array) =>
detectFaces(input),
);
ipcMain.handle("faceEmbedding", (_, input: Float32Array) =>
faceEmbedding(input),
);
// - File selection
ipcMain.handle("selectDirectory", (_) => selectDirectory());
ipcMain.handle("selectDirectory", () => selectDirectory());
ipcMain.handle("showUploadFilesDialog", (_) => showUploadFilesDialog());
ipcMain.handle("showUploadFilesDialog", () => showUploadFilesDialog());
ipcMain.handle("showUploadDirsDialog", (_) => showUploadDirsDialog());
ipcMain.handle("showUploadDirsDialog", () => showUploadDirsDialog());
ipcMain.handle("showUploadZipDialog", (_) => showUploadZipDialog());
ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
// - FS
ipcMain.handle("fsExists", (_, path) => fsExists(path));
// - FS Legacy
ipcMain.handle("checkExistsAndCreateDir", (_, dirPath) =>
checkExistsAndCreateDir(dirPath),
ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
fsRename(oldPath, newPath),
);
ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
ipcMain.handle("fsRm", (_, path: string) => fsRm(path));
// - FS Legacy
ipcMain.handle(
"saveStreamToDisk",
(_, path: string, fileStream: ReadableStream<any>) =>
(_, path: string, fileStream: ReadableStream) =>
saveStreamToDisk(path, fileStream),
);
ipcMain.handle("saveFileToDisk", (_, path: string, file: any) =>
saveFileToDisk(path, file),
ipcMain.handle("saveFileToDisk", (_, path: string, contents: string) =>
saveFileToDisk(path, contents),
);
ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path));
ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath));
ipcMain.handle("moveFile", (_, oldPath: string, newPath: string) =>
moveFile(oldPath, newPath),
);
ipcMain.handle("deleteFolder", (_, path: string) => deleteFolder(path));
ipcMain.handle("deleteFile", (_, path: string) => deleteFile(path));
ipcMain.handle("rename", (_, oldPath: string, newPath: string) =>
rename(oldPath, newPath),
);
// - Upload
ipcMain.handle("getPendingUploads", (_) => getPendingUploads());
ipcMain.handle("getPendingUploads", () => getPendingUploads());
ipcMain.handle(
"setToUploadFiles",
@ -252,7 +245,7 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
removeWatchMapping(watcher, folderPath),
);
ipcMain.handle("getWatchMappings", (_) => getWatchMappings());
ipcMain.handle("getWatchMappings", () => getWatchMappings());
ipcMain.handle(
"updateWatchMappingSyncedFiles",

View file

@ -15,10 +15,20 @@ import { isDev } from "./util";
*/
export const initLogging = () => {
log.transports.file.fileName = "ente.log";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB
log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}";
log.transports.console.level = false;
// Log unhandled errors and promise rejections.
log.errorHandler.startCatching({
onError: ({ error, errorName }) => {
logError(errorName, error);
// Prevent the default electron-log actions (e.g. showing a dialog)
// from getting triggered.
return false;
},
});
};
/**
@ -31,25 +41,7 @@ export const logToDisk = (message: string) => {
log.info(`[rndr] ${message}`);
};
export const logError = logErrorSentry;
/** Deprecated, but no alternative yet */
export function logErrorSentry(
error: any,
msg: string,
info?: Record<string, unknown>,
) {
logToDisk(
`error: ${error?.name} ${error?.message} ${
error?.stack
} msg: ${msg} info: ${JSON.stringify(info)}`,
);
if (isDev) {
console.log(error, { msg, info });
}
}
const logError1 = (message: string, e?: unknown) => {
const logError = (message: string, e?: unknown) => {
if (!e) {
logError_(message);
return;
@ -78,11 +70,14 @@ const logInfo = (...params: any[]) => {
.map((p) => (typeof p == "string" ? p : util.inspect(p)))
.join(" ");
log.info(`[main] ${message}`);
if (isDev) console.log(message);
if (isDev) console.log(`[info] ${message}`);
};
const logDebug = (param: () => any) => {
if (isDev) console.log(`[debug] ${util.inspect(param())}`);
if (isDev) {
const p = param();
console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);
}
};
/**
@ -98,12 +93,13 @@ export default {
* Log an error message with an optional associated error object.
*
* {@link e} is generally expected to be an `instanceof Error` but it can be
* any arbitrary object that we obtain, say, when in a try-catch handler.
* any arbitrary object that we obtain, say, when in a try-catch handler (in
* JavaScript any arbitrary value can be thrown).
*
* The log is written to disk. In development builds, the log is also
* printed to the (Node.js process') console.
* printed to the main (Node.js) process console.
*/
error: logError1,
error: logError,
/**
* Log a message.
*
@ -111,7 +107,7 @@ export default {
* arbitrary number of arbitrary parameters that it then serializes.
*
* The log is written to disk. In development builds, the log is also
* printed to the (Node.js process') console.
* printed to the main (Node.js) process console.
*/
info: logInfo,
/**
@ -121,11 +117,11 @@ export default {
* function to call to get the log message instead of directly taking the
* message. The provided function will only be called in development builds.
*
* The function can return an arbitrary value which is serialied before
* The function can return an arbitrary value which is serialized before
* being logged.
*
* This log is not written to disk. It is printed to the (Node.js process')
* console only on development builds.
* This log is NOT written to disk. And it is printed to the main (Node.js)
* process console, but only on development builds.
*/
debug: logDebug,
};

View file

@ -5,13 +5,10 @@ import {
MenuItemConstructorOptions,
shell,
} from "electron";
import { setIsAppQuitting } from "../main";
import { forceCheckForUpdateAndNotify } from "../services/appUpdater";
import autoLauncher from "../services/autoLauncher";
import {
getHideDockIconPreference,
setHideDockIconPreference,
} from "../services/userPreference";
import { allowWindowClose } from "../main";
import { forceCheckForAppUpdates } from "./services/app-update";
import autoLauncher from "./services/autoLauncher";
import { userPreferences } from "./stores/user-preferences";
import { openLogDirectory } from "./util";
/** Create and return the entries in the app's main menu bar */
@ -21,13 +18,12 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
// Whenever the menu is redrawn the current value of these variables is used
// to set the checked state for the various settings checkboxes.
let isAutoLaunchEnabled = await autoLauncher.isEnabled();
let shouldHideDockIcon = getHideDockIconPreference();
let shouldHideDockIcon = userPreferences.get("hideDockIcon");
const macOSOnly = (options: MenuItemConstructorOptions[]) =>
process.platform == "darwin" ? options : [];
const handleCheckForUpdates = () =>
forceCheckForUpdateAndNotify(mainWindow);
const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
const handleViewChangelog = () =>
shell.openExternal(
@ -40,7 +36,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
};
const toggleHideDockIcon = () => {
setHideDockIconPreference(!shouldHideDockIcon);
// Persist
userPreferences.set("hideDockIcon", !shouldHideDockIcon);
// And update the in-memory state
shouldHideDockIcon = !shouldHideDockIcon;
};
@ -54,7 +52,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
return Menu.buildFromTemplate([
{
label: "ente",
label: "Ente Photos",
submenu: [
...macOSOnly([
{
@ -156,7 +154,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
{ type: "separator" },
{ label: "Bring All to Front", role: "front" },
{ type: "separator" },
{ label: "Ente", role: "window" },
{ label: "Ente Photos", role: "window" },
]),
],
},
@ -197,7 +195,7 @@ export const createTrayContextMenu = (mainWindow: BrowserWindow) => {
};
const handleClose = () => {
setIsAppQuitting(true);
allowWindowClose();
app.quit();
};

View file

@ -0,0 +1,94 @@
import { compareVersions } from "compare-versions";
import { app, BrowserWindow } from "electron";
import { default as electronLog } from "electron-log";
import { autoUpdater } from "electron-updater";
import { allowWindowClose } from "../../main";
import { AppUpdateInfo } from "../../types/ipc";
import log from "../log";
import { userPreferences } from "../stores/user-preferences";
export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
autoUpdater.logger = electronLog;
autoUpdater.autoDownload = false;
const oneDay = 1 * 24 * 60 * 60 * 1000;
setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
checkForUpdatesAndNotify(mainWindow);
};
/**
* Check for app update check ignoring any previously saved skips / mutes.
*/
export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
userPreferences.delete("skipAppVersion");
userPreferences.delete("muteUpdateNotificationVersion");
checkForUpdatesAndNotify(mainWindow);
};
const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
const updateCheckResult = await autoUpdater.checkForUpdates();
if (!updateCheckResult) {
log.error("Failed to check for updates");
return;
}
const { version } = updateCheckResult.updateInfo;
log.debug(() => `Update check found version ${version}`);
if (compareVersions(version, app.getVersion()) <= 0) {
log.debug(() => "Skipping update, already at latest version");
return;
}
if (version === userPreferences.get("skipAppVersion")) {
log.info(`User chose to skip version ${version}`);
return;
}
const mutedVersion = userPreferences.get("muteUpdateNotificationVersion");
if (version === mutedVersion) {
log.info(`User has muted update notifications for version ${version}`);
return;
}
const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
mainWindow.webContents.send("appUpdateAvailable", updateInfo);
log.debug(() => "Attempting auto update");
autoUpdater.downloadUpdate();
let timeout: NodeJS.Timeout;
const fiveMinutes = 5 * 60 * 1000;
autoUpdater.on("update-downloaded", () => {
timeout = setTimeout(
() => showUpdateDialog({ autoUpdatable: true, version }),
fiveMinutes,
);
});
autoUpdater.on("error", (error) => {
clearTimeout(timeout);
log.error("Auto update failed", error);
showUpdateDialog({ autoUpdatable: false, version });
});
};
/**
* Return the version of the desktop app
*
* The return value is of the form `v1.2.3`.
*/
export const appVersion = () => `v${app.getVersion()}`;
export const updateAndRestart = () => {
log.info("Restarting the app to apply update");
allowWindowClose();
autoUpdater.quitAndInstall();
};
export const updateOnNextRestart = (version: string) =>
userPreferences.set("muteUpdateNotificationVersion", version);
export const skipAppUpdate = (version: string) =>
userPreferences.set("skipAppVersion", version);

View file

@ -1,5 +1,5 @@
import { AutoLauncherClient } from "../types/main";
import { isPlatform } from "../utils/common/platform";
import { AutoLauncherClient } from "../../types/main";
import { isPlatform } from "../platform";
import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher";
import macAutoLauncher from "./autoLauncherClients/macAutoLauncher";

View file

@ -1,6 +1,6 @@
import AutoLaunch from "auto-launch";
import { app } from "electron";
import { AutoLauncherClient } from "../../types/main";
import { AutoLauncherClient } from "../../../types/main";
const LAUNCHED_AS_HIDDEN_FLAG = "hidden";

View file

@ -1,5 +1,5 @@
import { app } from "electron";
import { AutoLauncherClient } from "../../types/main";
import { AutoLauncherClient } from "../../../types/main";
class MacAutoLauncher implements AutoLauncherClient {
async isEnabled() {

View file

@ -1,9 +1,9 @@
import chokidar from "chokidar";
import { BrowserWindow } from "electron";
import path from "path";
import { logError } from "../main/log";
import { getWatchMappings } from "../services/watch";
import log from "../log";
import { getElectronFile } from "./fs";
import { getWatchMappings } from "./watch";
/**
* Convert a file system {@link filePath} that uses the local system specific
@ -38,7 +38,7 @@ export function initWatcher(mainWindow: BrowserWindow) {
);
})
.on("error", (error) => {
logError(error, "error while watching files");
log.error("Error while watching files", error);
});
return watcher;

View file

@ -1,12 +1,11 @@
import pathToFfmpeg from "ffmpeg-static";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs";
import log from "../main/log";
import { execAsync } from "../main/util";
import { ElectronFile } from "../types/ipc";
import { generateTempFilePath, getTempDirPath } from "../utils/temp";
import { ElectronFile } from "../../types/ipc";
import { writeStream } from "../fs";
import log from "../log";
import { generateTempFilePath, getTempDirPath } from "../temp";
import { execAsync } from "../util";
const INPUT_PATH_PLACEHOLDER = "INPUT";
const FFMPEG_PLACEHOLDER = "FFMPEG";
@ -146,7 +145,7 @@ const promiseWithTimeout = async <T>(
} = { current: null };
const rejectOnTimeout = new Promise<null>((_, reject) => {
timeoutRef.current = setTimeout(
() => reject(Error(CustomErrors.WAIT_TIME_EXCEEDED)),
() => reject(new Error("Operation timed out")),
timeout,
);
});

View file

@ -2,8 +2,8 @@ import StreamZip from "node-stream-zip";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { logError } from "../main/log";
import { ElectronFile } from "../types/ipc";
import { ElectronFile } from "../../types/ipc";
import log from "../log";
const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
@ -115,7 +115,9 @@ export const getZipFileStream = async (
const inProgress = {
current: false,
};
// eslint-disable-next-line no-unused-vars
let resolveObj: (value?: any) => void = null;
// eslint-disable-next-line no-unused-vars
let rejectObj: (reason?: any) => void = null;
stream.on("readable", () => {
try {
@ -179,7 +181,7 @@ export const getZipFileStream = async (
controller.close();
}
} catch (e) {
logError(e, "readableStream pull failed");
log.error("Failed to pull from readableStream", e);
controller.close();
}
},

View file

@ -1,13 +1,12 @@
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "path";
import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs";
import { logError, logErrorSentry } from "../main/log";
import { execAsync, isDev } from "../main/util";
import { ElectronFile } from "../types/ipc";
import { isPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp";
import { CustomErrors, ElectronFile } from "../../types/ipc";
import { writeStream } from "../fs";
import log from "../log";
import { isPlatform } from "../platform";
import { generateTempFilePath } from "../temp";
import { execAsync, isDev } from "../util";
import { deleteTempFile } from "./ffmpeg";
const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK";
@ -103,18 +102,21 @@ async function convertToJPEG_(
return new Uint8Array(await fs.readFile(tempOutputFilePath));
} catch (e) {
logErrorSentry(e, "failed to convert heic");
log.error("Failed to convert HEIC", e);
throw e;
} finally {
try {
await fs.rm(tempInputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, "failed to remove tempInputFile");
log.error(`Failed to remove tempInputFile ${tempInputFilePath}`, e);
}
try {
await fs.rm(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, "failed to remove tempOutputFile");
log.error(
`Failed to remove tempOutputFile ${tempOutputFilePath}`,
e,
);
}
}
}
@ -150,7 +152,7 @@ function constructConvertCommand(
},
);
} else {
throw Error(CustomErrors.INVALID_OS(process.platform));
throw new Error(`Unsupported OS ${process.platform}`);
}
return convertCmd;
}
@ -187,7 +189,7 @@ export async function generateImageThumbnail(
try {
await deleteTempFile(inputFilePath);
} catch (e) {
logError(e, "failed to deleteTempFile");
log.error(`Failed to deleteTempFile ${inputFilePath}`, e);
}
}
}
@ -217,13 +219,16 @@ async function generateImageThumbnail_(
} while (thumbnail.length > maxSize && quality > MIN_QUALITY);
return thumbnail;
} catch (e) {
logErrorSentry(e, "generate image thumbnail failed");
log.error("Failed to generate image thumbnail", e);
throw e;
} finally {
try {
await fs.rm(tempOutputFilePath, { force: true });
} catch (e) {
logErrorSentry(e, "failed to remove tempOutputFile");
log.error(
`Failed to remove tempOutputFile ${tempOutputFilePath}`,
e,
);
}
}
}
@ -283,7 +288,7 @@ function constructThumbnailGenerationCommand(
return cmdPart;
});
} else {
throw Error(CustomErrors.INVALID_OS(process.platform));
throw new Error(`Unsupported OS ${process.platform}`);
}
return thumbnailGenerationCmd;
}

View file

@ -0,0 +1,248 @@
/**
* @file Compute CLIP embeddings for images and text.
*
* The embeddings are computed using ONNX runtime, with CLIP as the model.
*
* @see `web/apps/photos/src/services/clip-service.ts` for more details.
*/
import { existsSync } from "fs";
import jpeg from "jpeg-js";
import fs from "node:fs/promises";
import * as ort from "onnxruntime-node";
import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
import { CustomErrors } from "../../types/ipc";
import { writeStream } from "../fs";
import log from "../log";
import { generateTempFilePath } from "../temp";
import { deleteTempFile } from "./ffmpeg";
import {
createInferenceSession,
downloadModel,
modelPathDownloadingIfNeeded,
modelSavePath,
} from "./ml";
const textModelName = "clip-text-vit-32-uint8.onnx";
const textModelByteSize = 64173509; // 61.2 MB
const imageModelName = "clip-image-vit-32-float32.onnx";
const imageModelByteSize = 351468764; // 335.2 MB
let activeImageModelDownload: Promise<string> | undefined;
const imageModelPathDownloadingIfNeeded = async () => {
try {
if (activeImageModelDownload) {
log.info("Waiting for CLIP image model download to finish");
await activeImageModelDownload;
} else {
activeImageModelDownload = modelPathDownloadingIfNeeded(
imageModelName,
imageModelByteSize,
);
return await activeImageModelDownload;
}
} finally {
activeImageModelDownload = undefined;
}
};
let textModelDownloadInProgress = false;
/* TODO(MR): use the generic method. Then we can remove the exports for the
internal details functions that we use here */
const textModelPathDownloadingIfNeeded = async () => {
if (textModelDownloadInProgress)
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
const modelPath = modelSavePath(textModelName);
if (!existsSync(modelPath)) {
log.info("CLIP text model not found, downloading");
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
const localFileSize = (await fs.stat(modelPath)).size;
if (localFileSize !== textModelByteSize) {
log.error(
`CLIP text model size ${localFileSize} does not match the expected size, downloading again`,
);
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
}
}
return modelPath;
};
let imageSessionPromise: Promise<any> | undefined;
const onnxImageSession = async () => {
if (!imageSessionPromise) {
imageSessionPromise = (async () => {
const modelPath = await imageModelPathDownloadingIfNeeded();
return createInferenceSession(modelPath);
})();
}
return imageSessionPromise;
};
let _textSession: any = null;
const onnxTextSession = async () => {
if (!_textSession) {
const modelPath = await textModelPathDownloadingIfNeeded();
_textSession = await createInferenceSession(modelPath);
}
return _textSession;
};
export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
const tempFilePath = await generateTempFilePath("");
const imageStream = new Response(jpegImageData.buffer).body;
await writeStream(tempFilePath, imageStream);
try {
return await clipImageEmbedding_(tempFilePath);
} finally {
await deleteTempFile(tempFilePath);
}
};
const clipImageEmbedding_ = async (jpegFilePath: string) => {
const imageSession = await onnxImageSession();
const t1 = Date.now();
const rgbData = await getRGBData(jpegFilePath);
const feeds = {
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.debug(
() =>
`onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const imageEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(imageEmbedding);
};
const getRGBData = async (jpegFilePath: string) => {
const jpegData = await fs.readFile(jpegFilePath);
const rawImageData = jpeg.decode(jpegData, {
useTArray: true,
formatAsRGBA: false,
});
const nx: number = rawImageData.width;
const ny: number = rawImageData.height;
const inputImage: Uint8Array = rawImageData.data;
const nx2: number = 224;
const ny2: number = 224;
const totalSize: number = 3 * nx2 * ny2;
const result: number[] = Array(totalSize).fill(0);
const scale: number = Math.max(nx, ny) / 224;
const nx3: number = Math.round(nx / scale);
const ny3: number = Math.round(ny / scale);
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
for (let y = 0; y < ny3; y++) {
for (let x = 0; x < nx3; x++) {
for (let c = 0; c < 3; c++) {
// Linear interpolation
const sx: number = (x + 0.5) * scale - 0.5;
const sy: number = (y + 0.5) * scale - 0.5;
const x0: number = Math.max(0, Math.floor(sx));
const y0: number = Math.max(0, Math.floor(sy));
const x1: number = Math.min(x0 + 1, nx - 1);
const y1: number = Math.min(y0 + 1, ny - 1);
const dx: number = sx - x0;
const dy: number = sy - y0;
const j00: number = 3 * (y0 * nx + x0) + c;
const j01: number = 3 * (y0 * nx + x1) + c;
const j10: number = 3 * (y1 * nx + x0) + c;
const j11: number = 3 * (y1 * nx + x1) + c;
const v00: number = inputImage[j00];
const v01: number = inputImage[j01];
const v10: number = inputImage[j10];
const v11: number = inputImage[j11];
const v0: number = v00 * (1 - dx) + v01 * dx;
const v1: number = v10 * (1 - dx) + v11 * dx;
const v: number = v0 * (1 - dy) + v1 * dy;
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
// createTensorWithDataList is dumb compared to reshape and
// hence has to be given with one channel after another
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
result[i] = (v2 / 255 - mean[c]) / std[c];
}
}
}
return result;
};
const normalizeEmbedding = (embedding: Float32Array) => {
let normalization = 0;
for (let index = 0; index < embedding.length; index++) {
normalization += embedding[index] * embedding[index];
}
const sqrtNormalization = Math.sqrt(normalization);
for (let index = 0; index < embedding.length; index++) {
embedding[index] = embedding[index] / sqrtNormalization;
}
return embedding;
};
let _tokenizer: Tokenizer = null;
const getTokenizer = () => {
if (!_tokenizer) {
_tokenizer = new Tokenizer();
}
return _tokenizer;
};
export const clipTextEmbedding = async (text: string) => {
const imageSession = await onnxTextSession();
const t1 = Date.now();
const tokenizer = getTokenizer();
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
const feeds = {
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.debug(
() =>
`onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const textEmbedding = results["output"].data;
return normalizeEmbedding(textEmbedding);
};

View file

@ -0,0 +1,108 @@
/**
* @file Various face recognition related tasks.
*
* - Face detection with the YOLO model.
* - Face embedding with the MobileFaceNet model.
*
* The runtime used is ONNX.
*/
import * as ort from "onnxruntime-node";
import log from "../log";
import { createInferenceSession, modelPathDownloadingIfNeeded } from "./ml";
const faceDetectionModelName = "yolov5s_face_640_640_dynamic.onnx";
const faceDetectionModelByteSize = 30762872; // 29.3 MB
const faceEmbeddingModelName = "mobilefacenet_opset15.onnx";
const faceEmbeddingModelByteSize = 5286998; // 5 MB
let activeFaceDetectionModelDownload: Promise<string> | undefined;
const faceDetectionModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceDetectionModelDownload) {
log.info("Waiting for face detection model download to finish");
await activeFaceDetectionModelDownload;
} else {
activeFaceDetectionModelDownload = modelPathDownloadingIfNeeded(
faceDetectionModelName,
faceDetectionModelByteSize,
);
return await activeFaceDetectionModelDownload;
}
} finally {
activeFaceDetectionModelDownload = undefined;
}
};
let _faceDetectionSession: Promise<ort.InferenceSession> | undefined;
const faceDetectionSession = async () => {
if (!_faceDetectionSession) {
_faceDetectionSession =
faceDetectionModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceDetectionSession;
};
let activeFaceEmbeddingModelDownload: Promise<string> | undefined;
const faceEmbeddingModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceEmbeddingModelDownload) {
log.info("Waiting for face embedding model download to finish");
await activeFaceEmbeddingModelDownload;
} else {
activeFaceEmbeddingModelDownload = modelPathDownloadingIfNeeded(
faceEmbeddingModelName,
faceEmbeddingModelByteSize,
);
return await activeFaceEmbeddingModelDownload;
}
} finally {
activeFaceEmbeddingModelDownload = undefined;
}
};
let _faceEmbeddingSession: Promise<ort.InferenceSession> | undefined;
const faceEmbeddingSession = async () => {
if (!_faceEmbeddingSession) {
_faceEmbeddingSession =
faceEmbeddingModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceEmbeddingSession;
};
export const detectFaces = async (input: Float32Array) => {
const session = await faceDetectionSession();
const t = Date.now();
const feeds = {
input: new ort.Tensor("float32", input, [1, 3, 640, 640]),
};
const results = await session.run(feeds);
log.debug(() => `onnx/yolo face detection took ${Date.now() - t} ms`);
return results["output"].data;
};
export const faceEmbedding = async (input: Float32Array) => {
// Dimension of each face (alias)
const mobileFaceNetFaceSize = 112;
// Smaller alias
const z = mobileFaceNetFaceSize;
// Size of each face's data in the batch
const n = Math.round(input.length / (z * z * 3));
const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]);
const session = await faceEmbeddingSession();
const t = Date.now();
const feeds = { img_inputs: inputTensor };
const results = await session.run(feeds);
log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
// TODO: What's with this type? It works in practice, but double check.
return (results.embeddings as unknown as any)["cpuData"]; // as Float32Array;
};

View file

@ -0,0 +1,79 @@
/**
* @file AI/ML related functionality.
*
* @see also `ml-clip.ts`, `ml-face.ts`.
*
* The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models
* for various tasks are not shipped with the app but are downloaded on demand.
*
* The primary reason for doing these tasks in the Node.js layer is so that we
* can use the binary ONNX runtime which is 10-20x faster than the WASM based
* web one.
*/
import { app, net } from "electron/main";
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import * as ort from "onnxruntime-node";
import { writeStream } from "../fs";
import log from "../log";
/**
* Download the model named {@link modelName} if we don't already have it.
*
* Also verify that the size of the model we get matches {@expectedByteSize} (if
* not, redownload it).
*
* @returns the path to the model on the local machine.
*/
export const modelPathDownloadingIfNeeded = async (
modelName: string,
expectedByteSize: number,
) => {
const modelPath = modelSavePath(modelName);
if (!existsSync(modelPath)) {
log.info("CLIP image model not found, downloading");
await downloadModel(modelPath, modelName);
} else {
const size = (await fs.stat(modelPath)).size;
if (size !== expectedByteSize) {
log.error(
`The size ${size} of model ${modelName} does not match the expected size, downloading again`,
);
await downloadModel(modelPath, modelName);
}
}
return modelPath;
};
/** Return the path where the given {@link modelName} is meant to be saved */
export const modelSavePath = (modelName: string) =>
path.join(app.getPath("userData"), "models", modelName);
export const downloadModel = async (saveLocation: string, name: string) => {
// `mkdir -p` the directory where we want to save the model.
const saveDir = path.dirname(saveLocation);
await fs.mkdir(saveDir, { recursive: true });
// Download
log.info(`Downloading ML model from ${name}`);
const url = `https://models.ente.io/${name}`;
const res = await net.fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
// Save
await writeStream(saveLocation, res.body);
log.info(`Downloaded CLIP model ${name}`);
};
/**
* Crete an ONNX {@link InferenceSession} with some defaults.
*/
export const createInferenceSession = async (modelPath: string) => {
return await ort.InferenceSession.create(modelPath, {
// Restrict the number of threads to 1
intraOpNumThreads: 1,
// Be more conservative with RAM usage
enableCpuMemArena: false,
});
};

View file

@ -0,0 +1,25 @@
import { safeStorage } from "electron/main";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";
import { watchStore } from "../stores/watch.store";
export const clearStores = () => {
uploadStatusStore.clear();
keysStore.clear();
safeStorageStore.clear();
watchStore.clear();
};
export const saveEncryptionKey = async (encryptionKey: string) => {
const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
safeStorageStore.set("encryptionKey", b64EncryptedKey);
};
export const encryptionKey = async (): Promise<string | undefined> => {
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
if (!b64EncryptedKey) return undefined;
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
return await safeStorage.decryptString(keyBuffer);
};

View file

@ -1,9 +1,37 @@
import StreamZip from "node-stream-zip";
import path from "path";
import { ElectronFile, FILE_PATH_TYPE } from "../../types/ipc";
import { FILE_PATH_KEYS } from "../../types/main";
import { uploadStatusStore } from "../stores/upload.store";
import { ElectronFile, FILE_PATH_TYPE } from "../types/ipc";
import { FILE_PATH_KEYS } from "../types/main";
import { getValidPaths, getZipFileStream } from "./fs";
import { getElectronFile, getValidPaths, getZipFileStream } from "./fs";
export const getPendingUploads = async () => {
const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
const collectionName = uploadStatusStore.get("collectionName");
let files: ElectronFile[] = [];
let type: FILE_PATH_TYPE;
if (zipPaths.length) {
type = FILE_PATH_TYPE.ZIPS;
for (const zipPath of zipPaths) {
files = [
...files,
...(await getElectronFilesFromGoogleZip(zipPath)),
];
}
const pendingFilePaths = new Set(filePaths);
files = files.filter((file) => pendingFilePaths.has(file.path));
} else if (filePaths.length) {
type = FILE_PATH_TYPE.FILES;
files = await Promise.all(filePaths.map(getElectronFile));
}
return {
files,
collectionName,
type,
};
};
export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
const paths =

View file

@ -1,8 +1,7 @@
import type { FSWatcher } from "chokidar";
import ElectronLog from "electron-log";
import { WatchMapping, WatchStoreType } from "../../types/ipc";
import { watchStore } from "../stores/watch.store";
import { WatchMapping, WatchStoreType } from "../types/ipc";
import { isMappingPresent } from "../utils/watch";
export const addWatchMapping = async (
watcher: FSWatcher,
@ -29,6 +28,13 @@ export const addWatchMapping = async (
setWatchMappings(watchMappings);
};
function isMappingPresent(watchMappings: WatchMapping[], folderPath: string) {
const watchMapping = watchMappings?.find(
(mapping) => mapping.folderPath === folderPath,
);
return !!watchMapping;
}
export const removeWatchMapping = async (
watcher: FSWatcher,
folderPath: string,

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store";
import type { KeysStoreType } from "../types/main";
import type { KeysStoreType } from "../../types/main";
const keysStoreSchema: Schema<KeysStoreType> = {
AnonymizeUserID: {

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store";
import type { SafeStorageStoreType } from "../types/main";
import type { SafeStorageStoreType } from "../../types/main";
const safeStorageSchema: Schema<SafeStorageStoreType> = {
encryptionKey: {

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store";
import type { UploadStoreType } from "../types/main";
import type { UploadStoreType } from "../../types/main";
const uploadStoreSchema: Schema<UploadStoreType> = {
filePaths: {

View file

@ -1,7 +1,12 @@
import Store, { Schema } from "electron-store";
import type { UserPreferencesType } from "../types/main";
const userPreferencesSchema: Schema<UserPreferencesType> = {
interface UserPreferencesSchema {
hideDockIcon: boolean;
skipAppVersion?: string;
muteUpdateNotificationVersion?: string;
}
const userPreferencesSchema: Schema<UserPreferencesSchema> = {
hideDockIcon: {
type: "boolean",
},
@ -13,7 +18,7 @@ const userPreferencesSchema: Schema<UserPreferencesType> = {
},
};
export const userPreferencesStore = new Store({
export const userPreferences = new Store({
name: "userPreferences",
schema: userPreferencesSchema,
});

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store";
import { WatchStoreType } from "../types/ipc";
import { WatchStoreType } from "../../types/ipc";
const watchStoreSchema: Schema<WatchStoreType> = {
mappings: {

View file

@ -19,6 +19,7 @@
* curl -v -H "Location;" -H "User-Agent: FooBar's so-called ""Browser""" "http://www.daveeddy.com/?name=dave&age=24"
Which is suitable for being executed by the shell.
*/
/* eslint-disable no-unused-vars */
declare module "any-shell-escape" {
declare const shellescape: (args: readonly string | string[]) => string;
export default shellescape;

View file

@ -0,0 +1,9 @@
/**
* Types for [onnxruntime-node](https://onnxruntime.ai/docs/api/js/index.html).
*
* Note: these are not the official types but are based on a temporary
* [workaround](https://github.com/microsoft/onnxruntime/issues/17979).
*/
declare module "onnxruntime-node" {
export * from "onnxruntime-common";
}

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
/**
* @file The preload script
*
@ -31,9 +32,9 @@
* and when changing one of them, remember to see if the other two also need
* changing:
*
* - [renderer] web/packages/shared/electron/types.ts contains docs
* - [preload] desktop/src/preload.ts
* - [main] desktop/src/main/ipc.ts contains impl
* - [renderer] web/packages/next/types/electron.ts contains docs
* - [preload] desktop/src/preload.ts
* - [main] desktop/src/main/ipc.ts contains impl
*/
import { contextBridge, ipcRenderer } from "electron/renderer";
@ -44,7 +45,6 @@ import type {
AppUpdateInfo,
ElectronFile,
FILE_PATH_TYPE,
Model,
WatchMapping,
} from "./types/ipc";
@ -52,60 +52,66 @@ import type {
const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const openDirectory = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("openDirectory");
ipcRenderer.invoke("openDirectory", dirPath);
const openLogDirectory = (): Promise<void> =>
ipcRenderer.invoke("openLogDirectory");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const clearStores = () => ipcRenderer.send("clearStores");
const encryptionKey = (): Promise<string | undefined> =>
ipcRenderer.invoke("encryptionKey");
const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
const onMainWindowFocus = (cb?: () => void) => {
ipcRenderer.removeAllListeners("mainWindowFocus");
if (cb) ipcRenderer.on("mainWindowFocus", cb);
};
// - App update
const onAppUpdateAvailable = (
cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
) => {
ipcRenderer.removeAllListeners("appUpdateAvailable");
if (cb) {
ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) =>
cb(updateInfo),
);
}
};
const updateAndRestart = () => ipcRenderer.send("updateAndRestart");
const updateOnNextRestart = (version: string) =>
ipcRenderer.send("updateOnNextRestart", version);
const skipAppUpdate = (version: string) => {
ipcRenderer.send("skipAppUpdate", version);
};
const fsExists = (path: string): Promise<boolean> =>
ipcRenderer.invoke("fsExists", path);
const fsMkdirIfNeeded = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
const fsRename = (oldPath: string, newPath: string): Promise<void> =>
ipcRenderer.invoke("fsRename", oldPath, newPath);
const fsRmdir = (path: string): Promise<void> =>
ipcRenderer.invoke("fsRmdir", path);
const fsRm = (path: string): Promise<void> => ipcRenderer.invoke("fsRm", path);
// - AUDIT below this
const registerForegroundEventListener = (onForeground: () => void) => {
ipcRenderer.removeAllListeners("app-in-foreground");
ipcRenderer.on("app-in-foreground", () => {
onForeground();
});
};
const clearElectronStore = () => {
ipcRenderer.send("clear-electron-store");
};
const setEncryptionKey = (encryptionKey: string): Promise<void> =>
ipcRenderer.invoke("setEncryptionKey", encryptionKey);
const getEncryptionKey = (): Promise<string> =>
ipcRenderer.invoke("getEncryptionKey");
// - App update
const registerUpdateEventListener = (
showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
) => {
ipcRenderer.removeAllListeners("show-update-dialog");
ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => {
showUpdateDialog(updateInfo);
});
};
const updateAndRestart = () => {
ipcRenderer.send("update-and-restart");
};
const skipAppUpdate = (version: string) => {
ipcRenderer.send("skip-app-update", version);
};
const muteUpdateNotification = (version: string) => {
ipcRenderer.send("mute-update-notification", version);
};
// - Conversion
const convertToJPEG = (
@ -142,17 +148,17 @@ const runFFmpegCmd = (
// - ML
const computeImageEmbedding = (
model: Model,
imageData: Uint8Array,
): Promise<Float32Array> =>
ipcRenderer.invoke("computeImageEmbedding", model, imageData);
const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
const computeTextEmbedding = (
model: Model,
text: string,
): Promise<Float32Array> =>
ipcRenderer.invoke("computeTextEmbedding", model, text);
const clipTextEmbedding = (text: string): Promise<Float32Array> =>
ipcRenderer.invoke("clipTextEmbedding", text);
const detectFaces = (input: Float32Array): Promise<Float32Array> =>
ipcRenderer.invoke("detectFaces", input);
const faceEmbedding = (input: Float32Array): Promise<Float32Array> =>
ipcRenderer.invoke("faceEmbedding", input);
// - File selection
@ -223,16 +229,13 @@ const updateWatchMappingIgnoredFiles = (
// - FS Legacy
const checkExistsAndCreateDir = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("checkExistsAndCreateDir", dirPath);
const saveStreamToDisk = (
path: string,
fileStream: ReadableStream<any>,
fileStream: ReadableStream,
): Promise<void> => ipcRenderer.invoke("saveStreamToDisk", path, fileStream);
const saveFileToDisk = (path: string, file: any): Promise<void> =>
ipcRenderer.invoke("saveFileToDisk", path, file);
const saveFileToDisk = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("saveFileToDisk", path, contents);
const readTextFile = (path: string): Promise<string> =>
ipcRenderer.invoke("readTextFile", path);
@ -240,18 +243,6 @@ const readTextFile = (path: string): Promise<string> =>
const isFolder = (dirPath: string): Promise<boolean> =>
ipcRenderer.invoke("isFolder", dirPath);
const moveFile = (oldPath: string, newPath: string): Promise<void> =>
ipcRenderer.invoke("moveFile", oldPath, newPath);
const deleteFolder = (path: string): Promise<void> =>
ipcRenderer.invoke("deleteFolder", path);
const deleteFile = (path: string): Promise<void> =>
ipcRenderer.invoke("deleteFile", path);
const rename = (oldPath: string, newPath: string): Promise<void> =>
ipcRenderer.invoke("rename", oldPath, newPath);
// - Upload
const getPendingUploads = (): Promise<{
@ -308,24 +299,22 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
//
// The copy itself is relatively fast, but the problem with transfering large
// amounts of data is potentially running out of memory during the copy.
contextBridge.exposeInMainWorld("ElectronAPIs", {
contextBridge.exposeInMainWorld("electron", {
// - General
appVersion,
openDirectory,
registerForegroundEventListener,
clearElectronStore,
getEncryptionKey,
setEncryptionKey,
// - Logging
openLogDirectory,
logToDisk,
openDirectory,
openLogDirectory,
clearStores,
encryptionKey,
saveEncryptionKey,
onMainWindowFocus,
// - App update
onAppUpdateAvailable,
updateAndRestart,
updateOnNextRestart,
skipAppUpdate,
muteUpdateNotification,
registerUpdateEventListener,
// - Conversion
convertToJPEG,
@ -333,8 +322,10 @@ contextBridge.exposeInMainWorld("ElectronAPIs", {
runFFmpegCmd,
// - ML
computeImageEmbedding,
computeTextEmbedding,
clipImageEmbedding,
clipTextEmbedding,
detectFaces,
faceEmbedding,
// - File selection
selectDirectory,
@ -353,19 +344,18 @@ contextBridge.exposeInMainWorld("ElectronAPIs", {
// - FS
fs: {
exists: fsExists,
rename: fsRename,
mkdirIfNeeded: fsMkdirIfNeeded,
rmdir: fsRmdir,
rm: fsRm,
},
// - FS legacy
// TODO: Move these into fs + document + rename if needed
checkExistsAndCreateDir,
saveStreamToDisk,
saveFileToDisk,
readTextFile,
isFolder,
moveFile,
deleteFolder,
deleteFile,
rename,
// - Upload

View file

@ -1,133 +0,0 @@
import { compareVersions } from "compare-versions";
import { app, BrowserWindow } from "electron";
import { default as ElectronLog, default as log } from "electron-log";
import { autoUpdater } from "electron-updater";
import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
import { logErrorSentry } from "../main/log";
import { AppUpdateInfo } from "../types/ipc";
import {
clearMuteUpdateNotificationVersion,
clearSkipAppVersion,
getMuteUpdateNotificationVersion,
getSkipAppVersion,
setMuteUpdateNotificationVersion,
setSkipAppVersion,
} from "./userPreference";
const FIVE_MIN_IN_MICROSECOND = 5 * 60 * 1000;
const ONE_DAY_IN_MICROSECOND = 1 * 24 * 60 * 60 * 1000;
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.logger = log;
autoUpdater.autoDownload = false;
checkForUpdateAndNotify(mainWindow);
setInterval(
() => checkForUpdateAndNotify(mainWindow),
ONE_DAY_IN_MICROSECOND,
);
}
export function forceCheckForUpdateAndNotify(mainWindow: BrowserWindow) {
try {
clearSkipAppVersion();
clearMuteUpdateNotificationVersion();
checkForUpdateAndNotify(mainWindow);
} catch (e) {
logErrorSentry(e, "forceCheckForUpdateAndNotify failed");
}
}
async function checkForUpdateAndNotify(mainWindow: BrowserWindow) {
try {
log.debug("checkForUpdateAndNotify called");
const updateCheckResult = await autoUpdater.checkForUpdates();
log.debug("update version", updateCheckResult.updateInfo.version);
if (
compareVersions(
updateCheckResult.updateInfo.version,
app.getVersion(),
) <= 0
) {
log.debug("already at latest version");
return;
}
const skipAppVersion = getSkipAppVersion();
if (
skipAppVersion &&
updateCheckResult.updateInfo.version === skipAppVersion
) {
log.info(
"user chose to skip version ",
updateCheckResult.updateInfo.version,
);
return;
}
let timeout: NodeJS.Timeout;
log.debug("attempting auto update");
autoUpdater.downloadUpdate();
const muteUpdateNotificationVersion =
getMuteUpdateNotificationVersion();
if (
muteUpdateNotificationVersion &&
updateCheckResult.updateInfo.version ===
muteUpdateNotificationVersion
) {
log.info(
"user chose to mute update notification for version ",
updateCheckResult.updateInfo.version,
);
return;
}
autoUpdater.on("update-downloaded", () => {
timeout = setTimeout(
() =>
showUpdateDialog(mainWindow, {
autoUpdatable: true,
version: updateCheckResult.updateInfo.version,
}),
FIVE_MIN_IN_MICROSECOND,
);
});
autoUpdater.on("error", (error) => {
clearTimeout(timeout);
logErrorSentry(error, "auto update failed");
showUpdateDialog(mainWindow, {
autoUpdatable: false,
version: updateCheckResult.updateInfo.version,
});
});
setIsUpdateAvailable(true);
} catch (e) {
logErrorSentry(e, "checkForUpdateAndNotify failed");
}
}
export function updateAndRestart() {
ElectronLog.log("user quit the app");
setIsAppQuitting(true);
autoUpdater.quitAndInstall();
}
/**
* Return the version of the desktop app
*
* The return value is of the form `v1.2.3`.
*/
export const appVersion = () => `v${app.getVersion()}`;
export function skipAppUpdate(version: string) {
setSkipAppVersion(version);
}
export function muteUpdateNotification(version: string) {
setMuteUpdateNotificationVersion(version);
}
function showUpdateDialog(
mainWindow: BrowserWindow,
updateInfo: AppUpdateInfo,
) {
mainWindow.webContents.send("show-update-dialog", updateInfo);
}

View file

@ -1,506 +0,0 @@
import { app, net } from "electron/main";
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs";
import log, { logErrorSentry } from "../main/log";
import { execAsync, isDev } from "../main/util";
import { Model } from "../types/ipc";
import Tokenizer from "../utils/clip-bpe-ts/mod";
import { getPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp";
import { deleteTempFile } from "./ffmpeg";
const jpeg = require("jpeg-js");
const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL";
const GGMLCLIP_PATH_PLACEHOLDER = "GGML_PATH";
const INPUT_PATH_PLACEHOLDER = "INPUT";
const IMAGE_EMBEDDING_EXTRACT_CMD: string[] = [
GGMLCLIP_PATH_PLACEHOLDER,
"-mv",
CLIP_MODEL_PATH_PLACEHOLDER,
"--image",
INPUT_PATH_PLACEHOLDER,
];
const TEXT_EMBEDDING_EXTRACT_CMD: string[] = [
GGMLCLIP_PATH_PLACEHOLDER,
"-mt",
CLIP_MODEL_PATH_PLACEHOLDER,
"--text",
INPUT_PATH_PLACEHOLDER,
];
const ort = require("onnxruntime-node");
const TEXT_MODEL_DOWNLOAD_URL = {
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-text-model-f16.gguf",
onnx: "https://models.ente.io/clip-text-vit-32-uint8.onnx",
};
const IMAGE_MODEL_DOWNLOAD_URL = {
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-vision-model-f16.gguf",
onnx: "https://models.ente.io/clip-image-vit-32-float32.onnx",
};
const TEXT_MODEL_NAME = {
ggml: "clip-vit-base-patch32_ggml-text-model-f16.gguf",
onnx: "clip-text-vit-32-uint8.onnx",
};
const IMAGE_MODEL_NAME = {
ggml: "clip-vit-base-patch32_ggml-vision-model-f16.gguf",
onnx: "clip-image-vit-32-float32.onnx",
};
const IMAGE_MODEL_SIZE_IN_BYTES = {
ggml: 175957504, // 167.8 MB
onnx: 351468764, // 335.2 MB
};
const TEXT_MODEL_SIZE_IN_BYTES = {
ggml: 127853440, // 121.9 MB,
onnx: 64173509, // 61.2 MB
};
/** Return the path where the given {@link modelName} is meant to be saved */
const getModelSavePath = (modelName: string) =>
path.join(app.getPath("userData"), "models", modelName);
async function downloadModel(saveLocation: string, url: string) {
// confirm that the save location exists
const saveDir = path.dirname(saveLocation);
await fs.mkdir(saveDir, { recursive: true });
log.info("downloading clip model");
const res = await net.fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
await writeStream(saveLocation, res.body);
log.info("clip model downloaded");
}
let imageModelDownloadInProgress: Promise<void> = null;
export async function getClipImageModelPath(type: "ggml" | "onnx") {
try {
const modelSavePath = getModelSavePath(IMAGE_MODEL_NAME[type]);
if (imageModelDownloadInProgress) {
log.info("waiting for image model download to finish");
await imageModelDownloadInProgress;
} else {
if (!existsSync(modelSavePath)) {
log.info("clip image model not found, downloading");
imageModelDownloadInProgress = downloadModel(
modelSavePath,
IMAGE_MODEL_DOWNLOAD_URL[type],
);
await imageModelDownloadInProgress;
} else {
const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) {
log.info(
`clip image model size mismatch, downloading again got: ${localFileSize}`,
);
imageModelDownloadInProgress = downloadModel(
modelSavePath,
IMAGE_MODEL_DOWNLOAD_URL[type],
);
await imageModelDownloadInProgress;
}
}
}
return modelSavePath;
} finally {
imageModelDownloadInProgress = null;
}
}
let textModelDownloadInProgress: boolean = false;
export async function getClipTextModelPath(type: "ggml" | "onnx") {
const modelSavePath = getModelSavePath(TEXT_MODEL_NAME[type]);
if (textModelDownloadInProgress) {
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
if (!existsSync(modelSavePath)) {
log.info("clip text model not found, downloading");
textModelDownloadInProgress = true;
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
.catch(() => {
// ignore
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) {
log.info(
`clip text model size mismatch, downloading again got: ${localFileSize}`,
);
textModelDownloadInProgress = true;
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
.catch(() => {
// ignore
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
}
}
}
return modelSavePath;
}
function getGGMLClipPath() {
return isDev
? path.join("./build", `ggmlclip-${getPlatform()}`)
: path.join(process.resourcesPath, `ggmlclip-${getPlatform()}`);
}
async function createOnnxSession(modelPath: string) {
return await ort.InferenceSession.create(modelPath, {
intraOpNumThreads: 1,
enableCpuMemArena: false,
});
}
let onnxImageSessionPromise: Promise<any> = null;
async function getOnnxImageSession() {
if (!onnxImageSessionPromise) {
onnxImageSessionPromise = (async () => {
const clipModelPath = await getClipImageModelPath("onnx");
return createOnnxSession(clipModelPath);
})();
}
return onnxImageSessionPromise;
}
let onnxTextSession: any = null;
async function getOnnxTextSession() {
if (!onnxTextSession) {
const clipModelPath = await getClipTextModelPath("onnx");
onnxTextSession = await createOnnxSession(clipModelPath);
}
return onnxTextSession;
}
let tokenizer: Tokenizer = null;
function getTokenizer() {
if (!tokenizer) {
tokenizer = new Tokenizer();
}
return tokenizer;
}
export const computeImageEmbedding = async (
model: Model,
imageData: Uint8Array,
): Promise<Float32Array> => {
let tempInputFilePath = null;
try {
tempInputFilePath = await generateTempFilePath("");
const imageStream = new Response(imageData.buffer).body;
await writeStream(tempInputFilePath, imageStream);
const embedding = await computeImageEmbedding_(
model,
tempInputFilePath,
);
return embedding;
} catch (err) {
if (isExecError(err)) {
const parsedExecError = parseExecError(err);
throw Error(parsedExecError);
} else {
throw err;
}
} finally {
if (tempInputFilePath) {
await deleteTempFile(tempInputFilePath);
}
}
};
const isExecError = (err: any) => {
return err.message.includes("Command failed:");
};
const parseExecError = (err: any) => {
const errMessage = err.message;
if (errMessage.includes("Bad CPU type in executable")) {
return CustomErrors.UNSUPPORTED_PLATFORM(
process.platform,
process.arch,
);
} else {
return errMessage;
}
};
async function computeImageEmbedding_(
model: Model,
inputFilePath: string,
): Promise<Float32Array> {
if (!existsSync(inputFilePath)) {
throw Error(CustomErrors.INVALID_FILE_PATH);
}
if (model === Model.GGML_CLIP) {
return await computeGGMLImageEmbedding(inputFilePath);
} else if (model === Model.ONNX_CLIP) {
return await computeONNXImageEmbedding(inputFilePath);
} else {
throw Error(CustomErrors.INVALID_CLIP_MODEL(model));
}
}
export async function computeGGMLImageEmbedding(
inputFilePath: string,
): Promise<Float32Array> {
try {
const clipModelPath = await getClipImageModelPath("ggml");
const ggmlclipPath = getGGMLClipPath();
const cmd = IMAGE_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
return ggmlclipPath;
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
return clipModelPath;
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;
} else {
return cmdPart;
}
});
const { stdout } = await execAsync(cmd);
// parse stdout and return embedding
// get the last line of stdout
const lines = stdout.split("\n");
const lastLine = lines[lines.length - 1];
const embedding = JSON.parse(lastLine);
const embeddingArray = new Float32Array(embedding);
return embeddingArray;
} catch (err) {
log.error("Failed to compute GGML image embedding", err);
throw err;
}
}
export async function computeONNXImageEmbedding(
inputFilePath: string,
): Promise<Float32Array> {
try {
const imageSession = await getOnnxImageSession();
const t1 = Date.now();
const rgbData = await getRGBData(inputFilePath);
const feeds = {
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.info(
`onnx image embedding time: ${Date.now() - t1} ms (prep:${
t2 - t1
} ms, extraction: ${Date.now() - t2} ms)`,
);
const imageEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(imageEmbedding);
} catch (err) {
log.error("Failed to compute ONNX image embedding", err);
throw err;
}
}
export async function computeTextEmbedding(
model: Model,
text: string,
): Promise<Float32Array> {
try {
const embedding = computeTextEmbedding_(model, text);
return embedding;
} catch (err) {
if (isExecError(err)) {
const parsedExecError = parseExecError(err);
throw Error(parsedExecError);
} else {
throw err;
}
}
}
async function computeTextEmbedding_(
model: Model,
text: string,
): Promise<Float32Array> {
if (model === Model.GGML_CLIP) {
return await computeGGMLTextEmbedding(text);
} else {
return await computeONNXTextEmbedding(text);
}
}
export async function computeGGMLTextEmbedding(
text: string,
): Promise<Float32Array> {
try {
const clipModelPath = await getClipTextModelPath("ggml");
const ggmlclipPath = getGGMLClipPath();
const cmd = TEXT_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
return ggmlclipPath;
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
return clipModelPath;
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return text;
} else {
return cmdPart;
}
});
const { stdout } = await execAsync(cmd);
// parse stdout and return embedding
// get the last line of stdout
const lines = stdout.split("\n");
const lastLine = lines[lines.length - 1];
const embedding = JSON.parse(lastLine);
const embeddingArray = new Float32Array(embedding);
return embeddingArray;
} catch (err) {
if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) {
log.info(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
log.error("Failed to compute GGML text embedding", err);
}
throw err;
}
}
export async function computeONNXTextEmbedding(
text: string,
): Promise<Float32Array> {
try {
const imageSession = await getOnnxTextSession();
const t1 = Date.now();
const tokenizer = getTokenizer();
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
const feeds = {
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.info(
`onnx text embedding time: ${Date.now() - t1} ms (prep:${
t2 - t1
} ms, extraction: ${Date.now() - t2} ms)`,
);
const textEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(textEmbedding);
} catch (err) {
if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) {
log.info(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
logErrorSentry(err, "Error in computeONNXTextEmbedding");
}
throw err;
}
}
async function getRGBData(inputFilePath: string) {
const jpegData = await fs.readFile(inputFilePath);
let rawImageData;
try {
rawImageData = jpeg.decode(jpegData, {
useTArray: true,
formatAsRGBA: false,
});
} catch (err) {
logErrorSentry(err, "JPEG decode error");
throw err;
}
const nx: number = rawImageData.width;
const ny: number = rawImageData.height;
const inputImage: Uint8Array = rawImageData.data;
const nx2: number = 224;
const ny2: number = 224;
const totalSize: number = 3 * nx2 * ny2;
const result: number[] = Array(totalSize).fill(0);
const scale: number = Math.max(nx, ny) / 224;
const nx3: number = Math.round(nx / scale);
const ny3: number = Math.round(ny / scale);
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
for (let y = 0; y < ny3; y++) {
for (let x = 0; x < nx3; x++) {
for (let c = 0; c < 3; c++) {
// linear interpolation
const sx: number = (x + 0.5) * scale - 0.5;
const sy: number = (y + 0.5) * scale - 0.5;
const x0: number = Math.max(0, Math.floor(sx));
const y0: number = Math.max(0, Math.floor(sy));
const x1: number = Math.min(x0 + 1, nx - 1);
const y1: number = Math.min(y0 + 1, ny - 1);
const dx: number = sx - x0;
const dy: number = sy - y0;
const j00: number = 3 * (y0 * nx + x0) + c;
const j01: number = 3 * (y0 * nx + x1) + c;
const j10: number = 3 * (y1 * nx + x0) + c;
const j11: number = 3 * (y1 * nx + x1) + c;
const v00: number = inputImage[j00];
const v01: number = inputImage[j01];
const v10: number = inputImage[j10];
const v11: number = inputImage[j11];
const v0: number = v00 * (1 - dx) + v01 * dx;
const v1: number = v10 * (1 - dx) + v11 * dx;
const v: number = v0 * (1 - dy) + v1 * dy;
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
// createTensorWithDataList is dump compared to reshape and hence has to be given with one channel after another
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
result[i] = (v2 / 255 - mean[c]) / std[c];
}
}
}
return result;
}
export const computeClipMatchScore = async (
imageEmbedding: Float32Array,
textEmbedding: Float32Array,
) => {
if (imageEmbedding.length !== textEmbedding.length) {
throw Error("imageEmbedding and textEmbedding length mismatch");
}
let score = 0;
for (let index = 0; index < imageEmbedding.length; index++) {
score += imageEmbedding[index] * textEmbedding[index];
}
return score;
};
export const normalizeEmbedding = (embedding: Float32Array) => {
let normalization = 0;
for (let index = 0; index < embedding.length; index++) {
normalization += embedding[index] * embedding[index];
}
const sqrtNormalization = Math.sqrt(normalization);
for (let index = 0; index < embedding.length; index++) {
embedding[index] = embedding[index] / sqrtNormalization;
}
return embedding;
};

View file

@ -1,33 +0,0 @@
import { userPreferencesStore } from "../stores/userPreferences.store";
export function getHideDockIconPreference() {
return userPreferencesStore.get("hideDockIcon");
}
export function setHideDockIconPreference(shouldHideDockIcon: boolean) {
userPreferencesStore.set("hideDockIcon", shouldHideDockIcon);
}
export function getSkipAppVersion() {
return userPreferencesStore.get("skipAppVersion");
}
export function setSkipAppVersion(version: string) {
userPreferencesStore.set("skipAppVersion", version);
}
export function getMuteUpdateNotificationVersion() {
return userPreferencesStore.get("muteUpdateNotificationVersion");
}
export function setMuteUpdateNotificationVersion(version: string) {
userPreferencesStore.set("muteUpdateNotificationVersion", version);
}
export function clearSkipAppVersion() {
userPreferencesStore.delete("skipAppVersion");
}
export function clearMuteUpdateNotificationVersion() {
userPreferencesStore.delete("muteUpdateNotificationVersion");
}

View file

@ -4,6 +4,32 @@
* This file is manually kept in sync with the renderer code.
* See [Note: types.ts <-> preload.ts <-> ipc.ts]
*/
/**
* Errors that have special semantics on the web side.
*
* [Note: Custom errors across Electron/Renderer boundary]
*
* We need to use the `message` field to disambiguate between errors thrown by
* the main process when invoked from the renderer process. This is because:
*
* > Errors thrown throw `handle` in the main process are not transparent as
* > they are serialized and only the `message` property from the original error
* > is provided to the renderer process.
* >
* > - https://www.electronjs.org/docs/latest/tutorial/ipc
* >
* > Ref: https://github.com/electron/electron/issues/24427
*/
export const CustomErrors = {
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
"Windows native image processing is not supported",
UNSUPPORTED_PLATFORM: (platform: string, arch: string) =>
`Unsupported platform - ${platform} ${arch}`,
MODEL_DOWNLOAD_PENDING:
"Model download pending, skipping clip search request",
};
/**
* Deprecated - Use File + webUtils.getPathForFile instead
*
@ -45,6 +71,7 @@ export interface WatchStoreType {
}
export enum FILE_PATH_TYPE {
/* eslint-disable no-unused-vars */
FILES = "files",
ZIPS = "zips",
}
@ -53,8 +80,3 @@ export interface AppUpdateInfo {
autoUpdatable: boolean;
version: string;
}
export enum Model {
GGML_CLIP = "ggml-clip",
ONNX_CLIP = "onnx-clip",
}

View file

@ -18,6 +18,7 @@ export interface KeysStoreType {
};
}
/* eslint-disable no-unused-vars */
export const FILE_PATH_KEYS: {
[k in FILE_PATH_TYPE]: keyof UploadStoreType;
} = {
@ -28,9 +29,3 @@ export const FILE_PATH_KEYS: {
export interface SafeStorageStoreType {
encryptionKey: string;
}
export interface UserPreferencesType {
hideDockIcon: boolean;
skipAppVersion: string;
muteUpdateNotificationVersion: string;
}

View file

@ -1,11 +0,0 @@
import { WatchMapping } from "../types/ipc";
export function isMappingPresent(
watchMappings: WatchMapping[],
folderPath: string,
) {
const watchMapping = watchMappings?.find(
(mapping) => mapping.folderPath === folderPath,
);
return !!watchMapping;
}

View file

@ -139,7 +139,17 @@ export const sidebar = [
text: "Auth",
items: [
{ text: "Introduction", link: "/auth/" },
{ text: "FAQ", link: "/auth/faq/" },
{
text: "FAQ",
collapsed: true,
items: [
{ text: "General", link: "/auth/faq/" },
{
text: "Enteception",
link: "/auth/faq/enteception/",
},
],
},
{
text: "Migration",
collapsed: true,
@ -170,6 +180,10 @@ export const sidebar = [
text: "Connect to custom server",
link: "/self-hosting/guides/custom-server/",
},
{
text: "Hosting the web app",
link: "/self-hosting/guides/web-app",
},
{
text: "Administering your server",
link: "/self-hosting/guides/admin",
@ -197,6 +211,10 @@ export const sidebar = [
text: "Verification code",
link: "/self-hosting/faq/otp",
},
{
text: "Shared albums",
link: "/self-hosting/faq/sharing",
},
],
},
{

View file

@ -0,0 +1,51 @@
---
title: Enteception
description: Using Ente Auth to store 2FA for your Ente account
---
# Enteception
Your 2FA codes are in Ente Auth, but if you enable 2FA for your Ente account
itself, where should the 2FA for your Ente account be stored?
There are multiple answers, none of which are better or worse, they just depend
on your situation and risk tolerance.
If you are using the same account for both Ente Photos and Ente Auth and have
enabled 2FA from the Ente Photos app, we recommend that you ensure you store
your recovery key in a safe place (writing it down on a paper is a good idea).
This key can be used to bypass Ente 2FA in case you are locked out.
Another option is to use a separate account for Ente Auth.
Also, taking exporting the encrypted backup is also another good way to reduce
the risk (you can easily import the encrypted backup without signing in).
Finally, we have on our roadmap some features like adding support for
emergency/legacy-contacts, passkeys, and hardware security keys. Beyond other
benefits, all of these would further reduce the risk of users getting locked out
of their accounts.
## Email verification for Ente Auth
There is a related ouroboros scenario where if email verification is enabled in
the Ente Auth app _and_ the 2FA for your email provider is stored in Ente Auth,
then you might need a code from your email to log into Ente Auth, but to log
into your email you needed the Auth code.
To prevent people from accidentally locking themselves out this way, email
verification is disabled by default in the auth app. We also try to show a
warning when you try to enable email verification in the auth app:
<div align="center">
![Warning shown when enabling 2FA in Ente Auth](warning.png){width=400px}
</div>
The solution here are the same as the Ente-in-Ente case.
## TL;DR;
Ideally, you should **note down your recovery key in a safe place (may be on a
paper)**, using which you will be able to by-pass the two factor.

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

View file

@ -31,3 +31,22 @@ You can enable FaceID lock under Settings → Security → Lockscreen.
### Why does the desktop and mobile app displays different code?
Please verify that the time on both your mobile and desktop is same.
### Does ente Authenticator require an account?
Answer: No, ente Authenticator does not require an account. You can choose to
use the app without backups if you prefer.
### Can I use the Ente 2FA app on multiple devices and sync them?
Yes, you can download the Ente app on multiple devices and sync the codes,
end-to-end encrypted.
### What does it mean when I receive a message saying my current device is not powerful enough to verify my password?
This means that the parameters that were used to derive your master-key on your
original device, are incompatible with your current device (likely because it's
less powerful).
If you recover your account via your current device and reset the password, it
will re-generate a key that will be compatible on both devices.

View file

@ -12,15 +12,17 @@ in a local drive or NAS of your choice. This way, you can use Ente in your day
to day use, but will have an additional guarantee that a copy of your original
photos and videos are always available in normal directories and files.
* You can use [Ente's CLI](https://github.com/ente-io/ente/tree/main/cli#export)
to export your data in a cron job to a location of your choice. The exports
are incremental, and will also gracefully handle interruptions.
- You can use
[Ente's CLI](https://github.com/ente-io/ente/tree/main/cli#export) to export
your data in a cron job to a location of your choice. The exports are
incremental, and will also gracefully handle interruptions.
* Similarly, you can use Ente's [desktop app](https://ente.io/download/desktop)
to export your data to a folder of your choice. The desktop app also supports
"continuous" exports, where it will automatically export new items in the
background without you needing to run any other cron jobs. See
[migration/export](/photos/migration/export/) for more details.
- Similarly, you can use Ente's
[desktop app](https://ente.io/download/desktop) to export your data to a
folder of your choice. The desktop app also supports "continuous" exports,
where it will automatically export new items in the background without you
needing to run any other cron jobs. See
[migration/export](/photos/migration/export/) for more details.
## Does the exported data from Ente photos preserve the same folder and album structure as in the app?

View file

@ -77,26 +77,45 @@ It's like cafe 😊. kaf-_ay_. en-_tay_.
## Does Ente apply compression to uploaded photos?
Ente does not apply compression to uploaded photos. The file size of your photos in Ente will be similar to the original file sizes you have.
Ente does not apply compression to uploaded photos. The file size of your photos
in Ente will be similar to the original file sizes you have.
## Can I add photos from a shared album to albums that I created in Ente?
Currently, Ente does not support adding photos from a shared album to your personal albums. If you want to include photos from a shared album in your own albums, you will need to ask the owner of the photos to add them to your album.
Currently, Ente does not support adding photos from a shared album to your
personal albums. If you want to include photos from a shared album in your own
albums, you will need to ask the owner of the photos to add them to your album.
## How do I ensure that the Ente desktop app stays up to date on my system?
Ente desktop includes an auto-update feature, ensuring that whenever updates are deployed, the app will automatically download and install them. You don't need to manually update the software.
Ente desktop includes an auto-update feature, ensuring that whenever updates are
deployed, the app will automatically download and install them. You don't need
to manually update the software.
## Can I sync a folder containing multiple subfolders, each representing an album?
Yes, when you drag and drop the folder onto the desktop app, the app will detect the multiple folders and prompt you to choose whether you want to create a single album or separate albums for each folder.
Yes, when you drag and drop the folder onto the desktop app, the app will detect
the multiple folders and prompt you to choose whether you want to create a
single album or separate albums for each folder.
## What is the difference between **Magic** and **Content** search results on the desktop?
**Magic** is where you can search for long queries. Like, "baby in red dress", or "dog playing at the beach".
**Magic** is where you can search for long queries. Like, "baby in red dress",
or "dog playing at the beach".
**Content** is where you can search for single-words. Like, "car" or "pizza".
## How do I identify which files experienced upload issues within the desktop app?
Check the sections within the upload progress bar for "Failed Uploads," "Ignored Uploads," and "Unsuccessful Uploads."
Check the sections within the upload progress bar for "Failed Uploads," "Ignored
Uploads," and "Unsuccessful Uploads."
## How do i keep NAS and Ente photos synced?
Please try using our CLI to pull data into your NAS
https://github.com/ente-io/ente/tree/main/cli#readme .
## Is there a way to view all albums on the map view?
Currently, the Ente mobile app allows you to see a map view of all the albums by
clicking on "Your map" under "Locations" on the search screen.

View file

@ -80,3 +80,10 @@ and is never sent to our servers.
Please note that only users on the paid plan are allowed to share albums. The
receiver just needs a free Ente account.
## Has the Ente Photos app been audited by a credible source?
Yes, Ente Photos has undergone a thorough security audit conducted by Cure53, in
collaboration with Symbolic Software. Cure53 is a prominent German cybersecurity
firm, while Symbolic Software specializes in applied cryptography. Please find
the full report here: https://ente.io/blog/cryptography-audit/

View file

@ -159,4 +159,6 @@ We do offer a generous free trial for you to experience the product.
## Will I need to pay for Ente Auth after my Ente Photos free plan expires?
No, you will not need to pay for Ente Auth after your Ente Photos free plan expires. Ente Auth is completely free to use, and the expiration of your Ente Photos free plan will not impact your ability to access or use Ente Auth.
No, you will not need to pay for Ente Auth after your Ente Photos free plan
expires. Ente Auth is completely free to use, and the expiration of your Ente
Photos free plan will not impact your ability to access or use Ente Auth.

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