From 6c7c86f346a3e2fab031276de6637ada7a844f53 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 19 Jun 2026 00:39:03 +0800 Subject: [PATCH] feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650) * feat(applock): wire up biometric plugin + biometricUnlockEnabled setting Co-Authored-By: Claude Sonnet 4.6 * feat(applock): add guarded biometric service wrapper * feat(applock): auto-prompt biometrics on the lock screen with PIN fallback Co-Authored-By: Claude Sonnet 4.6 * fix(applock): guard concurrent biometric prompts + tighten lock-screen tests - Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls - Assert PIN input still rendered after biometric failure (test 2) - Replace flaky waitFor negative assertion with a 50ms flush in test 3 Co-Authored-By: Claude Sonnet 4.6 * feat(applock): mobile biometric toggle + default-on at PIN setup Co-Authored-By: Claude Sonnet 4.6 * chore(i18n): add biometric app-lock strings * fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps Co-Authored-By: Claude Sonnet 4.6 * build(applock): pin tauri-plugin-biometric in Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- Cargo.lock | 16 +++ apps/readest-app/package.json | 1 + .../public/locales/ar/translation.json | 9 +- .../public/locales/bn/translation.json | 9 +- .../public/locales/bo/translation.json | 9 +- .../public/locales/de/translation.json | 9 +- .../public/locales/el/translation.json | 9 +- .../public/locales/es/translation.json | 9 +- .../public/locales/fa/translation.json | 9 +- .../public/locales/fr/translation.json | 9 +- .../public/locales/he/translation.json | 9 +- .../public/locales/hi/translation.json | 9 +- .../public/locales/hu/translation.json | 9 +- .../public/locales/id/translation.json | 9 +- .../public/locales/it/translation.json | 9 +- .../public/locales/ja/translation.json | 9 +- .../public/locales/ko/translation.json | 9 +- .../public/locales/ms/translation.json | 9 +- .../public/locales/nl/translation.json | 9 +- .../public/locales/pl/translation.json | 9 +- .../public/locales/pt-BR/translation.json | 9 +- .../public/locales/pt/translation.json | 9 +- .../public/locales/ro/translation.json | 9 +- .../public/locales/ru/translation.json | 9 +- .../public/locales/si/translation.json | 9 +- .../public/locales/sl/translation.json | 9 +- .../public/locales/sv/translation.json | 9 +- .../public/locales/ta/translation.json | 9 +- .../public/locales/th/translation.json | 9 +- .../public/locales/tr/translation.json | 9 +- .../public/locales/uk/translation.json | 9 +- .../public/locales/uz/translation.json | 9 +- .../public/locales/vi/translation.json | 9 +- .../public/locales/zh-CN/translation.json | 9 +- .../public/locales/zh-TW/translation.json | 9 +- apps/readest-app/src-tauri/Cargo.toml | 1 + apps/readest-app/src-tauri/Info.plist | 2 + .../src-tauri/capabilities/default.json | 1 + apps/readest-app/src-tauri/src/lib.rs | 3 + .../components/AppLockScreen.test.tsx | 79 +++++++++++ .../src/__tests__/services/biometric.test.ts | 133 ++++++++++++++++++ .../app/library/components/SettingsMenu.tsx | 40 +++++- .../src/components/AppLockScreen.tsx | 64 ++++++++- apps/readest-app/src/components/Providers.tsx | 1 + .../src/components/settings/AppLockDialog.tsx | 16 ++- apps/readest-app/src/services/biometric.ts | 59 ++++++++ apps/readest-app/src/store/appLockStore.ts | 18 ++- apps/readest-app/src/types/settings.ts | 8 ++ pnpm-lock.yaml | 10 ++ 49 files changed, 709 insertions(+), 40 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/AppLockScreen.test.tsx create mode 100644 apps/readest-app/src/__tests__/services/biometric.test.ts create mode 100644 apps/readest-app/src/services/biometric.ts diff --git a/Cargo.lock b/Cargo.lock index 06162c0d..49153bd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build 2.6.2", + "tauri-plugin-biometric", "tauri-plugin-cli", "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", @@ -7694,6 +7695,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-biometric" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "306175b744890b5e4aeb8add6aae7debec1b1504c5087bd0310ed7c9c6feae38" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-cli" version = "2.4.1" diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index a3d514c5..f9265517 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -129,6 +129,7 @@ "@supabase/auth-ui-shared": "^0.1.8", "@supabase/supabase-js": "^2.76.1", "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-biometric": "^2.3.2", "@tauri-apps/plugin-cli": "^2.4.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-deep-link": "^2.4.7", diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 8c18115e..a159e174 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1898,5 +1898,12 @@ "Erase the book and all its data, including reading progress": "محو الكتاب وجميع بياناته، بما في ذلك تقدم القراءة", "More Actions": "إجراءات أخرى", "Sign in and make the book available to share it": "سجّل الدخول واجعل الكتاب متاحًا لمشاركته", - "Download the book to export it": "نزّل الكتاب لتصديره" + "Download the book to export it": "نزّل الكتاب لتصديره", + "Unlock Readest": "فتح قفل Readest", + "Use {{biometry}}": "استخدام {{biometry}}", + "Unlock with {{biometry}}": "فتح القفل بـ {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "يتطلب رمز PIN (والبيومترية، إن توفّرت) لفتح Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "البيومترية" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 882fbe16..9938d382 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "পড়ার অগ্রগতিসহ বই ও এর সমস্ত ডেটা মুছে ফেলুন", "More Actions": "আরও ক্রিয়া", "Sign in and make the book available to share it": "শেয়ার করতে সাইন ইন করুন এবং বইটি উপলব্ধ করুন", - "Download the book to export it": "এক্সপোর্ট করতে বইটি ডাউনলোড করুন" + "Download the book to export it": "এক্সপোর্ট করতে বইটি ডাউনলোড করুন", + "Unlock Readest": "Readest আনলক করুন", + "Use {{biometry}}": "{{biometry}} ব্যবহার করুন", + "Unlock with {{biometry}}": "{{biometry}} দিয়ে আনলক করুন", + "Require a PIN (and biometrics, if available) to open Readest": "Readest খুলতে PIN (এবং উপলব্ধ হলে বায়োমেট্রিক্স) প্রয়োজন", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "বায়োমেট্রিক্স" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 20ef0a76..7052fa15 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "དཔེ་དེབ་དང་ཀློག་འདོན་གྱི་འཕེལ་རིམ་བཅས་ཀྱི་གཞི་གྲངས་ཡོངས་སུ་བསུབ།", "More Actions": "བྱ་སྤྱོད་གཞན་དག", "Sign in and make the book available to share it": "ནང་འཛུལ་བྱས་ནས་དཔེ་དེབ་མཁོ་སྤྲོད་རུང་བར་བཟོས་ནས་མཉམ་སྤྱོད་བྱོས།", - "Download the book to export it": "ཕྱིར་འདྲེན་བྱེད་པར་དཔེ་དེབ་ཕབ་ལེན་བྱོས།" + "Download the book to export it": "ཕྱིར་འདྲེན་བྱེད་པར་དཔེ་དེབ་ཕབ་ལེན་བྱོས།", + "Unlock Readest": "Readest སྒོ་ལྕགས་ཕྱེ།", + "Use {{biometry}}": "{{biometry}} བེད་སྤྱོད།", + "Unlock with {{biometry}}": "{{biometry}} ཐོག་ནས་སྒོ་ལྕགས་ཕྱེ།", + "Require a PIN (and biometrics, if available) to open Readest": "Readest ཕྱེ་བར་ PIN (དང་གལ་ཏེ་ཡོད་ན་ལུས་རྟགས་ངོ་སྤྲོད།) དགོས།", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "ལུས་རྟགས་ངོ་སྤྲོད།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index e16a6d06..119b88c1 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Buch und alle Daten löschen, einschließlich Lesefortschritt", "More Actions": "Weitere Aktionen", "Sign in and make the book available to share it": "Melde dich an und mache das Buch verfügbar, um es zu teilen", - "Download the book to export it": "Lade das Buch herunter, um es zu exportieren" + "Download the book to export it": "Lade das Buch herunter, um es zu exportieren", + "Unlock Readest": "Readest entsperren", + "Use {{biometry}}": "{{biometry}} verwenden", + "Unlock with {{biometry}}": "Mit {{biometry}} entsperren", + "Require a PIN (and biometrics, if available) to open Readest": "PIN (und Biometrie, falls verfügbar) zum Öffnen von Readest erforderlich", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "Biometrie" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index bb44eff4..ea889492 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Διαγράφει το βιβλίο και όλα τα δεδομένα του, συμπεριλαμβανομένης της προόδου ανάγνωσης", "More Actions": "Περισσότερες ενέργειες", "Sign in and make the book available to share it": "Συνδεθείτε και κάντε το βιβλίο διαθέσιμο για να το μοιραστείτε", - "Download the book to export it": "Κατεβάστε το βιβλίο για να το εξαγάγετε" + "Download the book to export it": "Κατεβάστε το βιβλίο για να το εξαγάγετε", + "Unlock Readest": "Ξεκλείδωμα του Readest", + "Use {{biometry}}": "Χρήση {{biometry}}", + "Unlock with {{biometry}}": "Ξεκλείδωμα με {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Απαιτείται PIN (και βιομετρικά, εάν διατίθενται) για το άνοιγμα του Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "βιομετρικά" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index a006e5c2..6bded3f3 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Borra el libro y todos sus datos, incluido el progreso de lectura", "More Actions": "Más acciones", "Sign in and make the book available to share it": "Inicia sesión y ten el libro disponible para compartirlo", - "Download the book to export it": "Descarga el libro para exportarlo" + "Download the book to export it": "Descarga el libro para exportarlo", + "Unlock Readest": "Desbloquear Readest", + "Use {{biometry}}": "Usar {{biometry}}", + "Unlock with {{biometry}}": "Desbloquear con {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Requiere un PIN (y biometría, si está disponible) para abrir Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometría" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index b447df70..3468ae13 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "کتاب و تمام داده‌های آن، شامل پیشرفت مطالعه را پاک می‌کند", "More Actions": "اقدامات بیشتر", "Sign in and make the book available to share it": "برای اشتراک‌گذاری، وارد شوید و کتاب را در دسترس قرار دهید", - "Download the book to export it": "برای خروجی گرفتن، کتاب را دانلود کنید" + "Download the book to export it": "برای خروجی گرفتن، کتاب را دانلود کنید", + "Unlock Readest": "باز کردن قفل Readest", + "Use {{biometry}}": "استفاده از {{biometry}}", + "Unlock with {{biometry}}": "باز کردن قفل با {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "برای باز کردن Readest به PIN (و در صورت موجود بودن، بیومتریک) نیاز است", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "بیومتریک" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 0e5eef5e..cfbbe2a3 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Supprime le livre et toutes ses données, y compris la progression de lecture", "More Actions": "Plus d'actions", "Sign in and make the book available to share it": "Connectez-vous et rendez le livre disponible pour le partager", - "Download the book to export it": "Téléchargez le livre pour l'exporter" + "Download the book to export it": "Téléchargez le livre pour l'exporter", + "Unlock Readest": "Déverrouiller Readest", + "Use {{biometry}}": "Utiliser {{biometry}}", + "Unlock with {{biometry}}": "Déverrouiller avec {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Exiger un code PIN (et la biométrie, si disponible) pour ouvrir Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biométrie" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index ccaf7a18..2846c1c7 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "מחיקת הספר וכל הנתונים שלו, כולל התקדמות הקריאה", "More Actions": "פעולות נוספות", "Sign in and make the book available to share it": "היכנס והפוך את הספר לזמין כדי לשתף אותו", - "Download the book to export it": "הורד את הספר כדי לייצא אותו" + "Download the book to export it": "הורד את הספר כדי לייצא אותו", + "Unlock Readest": "פתח את Readest", + "Use {{biometry}}": "השתמש ב-{{biometry}}", + "Unlock with {{biometry}}": "פתח עם {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "דרוש קוד PIN (וביומטריה, אם זמינה) כדי לפתוח את Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "ביומטריה" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index e16f959d..73fe9922 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "पढ़ने की प्रगति सहित पुस्तक और उसका सारा डेटा मिटाएँ", "More Actions": "अधिक क्रियाएँ", "Sign in and make the book available to share it": "साझा करने के लिए साइन इन करें और पुस्तक उपलब्ध कराएँ", - "Download the book to export it": "निर्यात करने के लिए पुस्तक डाउनलोड करें" + "Download the book to export it": "निर्यात करने के लिए पुस्तक डाउनलोड करें", + "Unlock Readest": "Readest अनलॉक करें", + "Use {{biometry}}": "{{biometry}} उपयोग करें", + "Unlock with {{biometry}}": "{{biometry}} से अनलॉक करें", + "Require a PIN (and biometrics, if available) to open Readest": "Readest खोलने के लिए PIN (और यदि उपलब्ध हो तो बायोमेट्रिक्स) आवश्यक है", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "बायोमेट्रिक्स" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 6b210a62..2fff1ae7 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Törli a könyvet és minden adatát, beleértve az olvasási előrehaladást", "More Actions": "További műveletek", "Sign in and make the book available to share it": "Jelentkezz be, és tedd elérhetővé a könyvet a megosztáshoz", - "Download the book to export it": "Töltsd le a könyvet az exportáláshoz" + "Download the book to export it": "Töltsd le a könyvet az exportáláshoz", + "Unlock Readest": "Readest feloldása", + "Use {{biometry}}": "{{biometry}} használata", + "Unlock with {{biometry}}": "Feloldás {{biometry}} segítségével", + "Require a PIN (and biometrics, if available) to open Readest": "PIN-kód (és biometria, ha elérhető) szükséges a Readest megnyitásához", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometria" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 9c5046ad..91133086 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "Menghapus buku dan semua datanya, termasuk progres baca", "More Actions": "Tindakan lainnya", "Sign in and make the book available to share it": "Masuk dan sediakan buku untuk membagikannya", - "Download the book to export it": "Unduh buku untuk mengekspornya" + "Download the book to export it": "Unduh buku untuk mengekspornya", + "Unlock Readest": "Buka kunci Readest", + "Use {{biometry}}": "Gunakan {{biometry}}", + "Unlock with {{biometry}}": "Buka kunci dengan {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Memerlukan PIN (dan biometrik, jika tersedia) untuk membuka Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrik" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 8e6d6766..42c29559 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Elimina il libro e tutti i suoi dati, inclusi i progressi di lettura", "More Actions": "Altre azioni", "Sign in and make the book available to share it": "Accedi e rendi disponibile il libro per condividerlo", - "Download the book to export it": "Scarica il libro per esportarlo" + "Download the book to export it": "Scarica il libro per esportarlo", + "Unlock Readest": "Sblocca Readest", + "Use {{biometry}}": "Usa {{biometry}}", + "Unlock with {{biometry}}": "Sblocca con {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Richiede un PIN (e la biometria, se disponibile) per aprire Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometria" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 14babbb9..87ed0d10 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "本と読書の進行状況を含むすべてのデータを消去します", "More Actions": "その他の操作", "Sign in and make the book available to share it": "共有するにはサインインして書籍を利用可能にしてください", - "Download the book to export it": "エクスポートするには書籍をダウンロードしてください" + "Download the book to export it": "エクスポートするには書籍をダウンロードしてください", + "Unlock Readest": "Readest のロックを解除", + "Use {{biometry}}": "{{biometry}} を使用", + "Unlock with {{biometry}}": "{{biometry}} でロック解除", + "Require a PIN (and biometrics, if available) to open Readest": "Readest を開くには PIN(および利用可能な場合は生体認証)が必要です", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "生体認証" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index defe08a1..0b26f62c 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "읽기 진행률을 포함한 책과 모든 데이터를 삭제합니다", "More Actions": "추가 작업", "Sign in and make the book available to share it": "공유하려면 로그인하고 책을 사용할 수 있도록 하세요", - "Download the book to export it": "내보내려면 책을 다운로드하세요" + "Download the book to export it": "내보내려면 책을 다운로드하세요", + "Unlock Readest": "Readest 잠금 해제", + "Use {{biometry}}": "{{biometry}} 사용", + "Unlock with {{biometry}}": "{{biometry}}로 잠금 해제", + "Require a PIN (and biometrics, if available) to open Readest": "Readest를 열려면 PIN(및 사용 가능한 경우 생체 인증)이 필요합니다", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "생체 인증" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 742bf90d..33c24d38 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "Memadam buku dan semua datanya, termasuk kemajuan bacaan", "More Actions": "Tindakan lain", "Sign in and make the book available to share it": "Log masuk dan sediakan buku untuk berkongsinya", - "Download the book to export it": "Muat turun buku untuk mengeksportnya" + "Download the book to export it": "Muat turun buku untuk mengeksportnya", + "Unlock Readest": "Buka kunci Readest", + "Use {{biometry}}": "Guna {{biometry}}", + "Unlock with {{biometry}}": "Buka kunci dengan {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Memerlukan PIN (dan biometrik, jika tersedia) untuk membuka Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrik" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 695b8b86..a48194a4 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Wist het boek en alle gegevens, inclusief leesvoortgang", "More Actions": "Meer acties", "Sign in and make the book available to share it": "Meld je aan en maak het boek beschikbaar om het te delen", - "Download the book to export it": "Download het boek om het te exporteren" + "Download the book to export it": "Download het boek om het te exporteren", + "Unlock Readest": "Readest ontgrendelen", + "Use {{biometry}}": "{{biometry}} gebruiken", + "Unlock with {{biometry}}": "Ontgrendelen met {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Vereist een pincode (en biometrie, indien beschikbaar) om Readest te openen", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrie" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 33a66334..77728cfb 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1832,5 +1832,12 @@ "Erase the book and all its data, including reading progress": "Usuwa książkę i wszystkie jej dane, w tym postęp czytania", "More Actions": "Więcej akcji", "Sign in and make the book available to share it": "Zaloguj się i pobierz książkę, aby ją udostępnić", - "Download the book to export it": "Pobierz książkę, aby ją wyeksportować" + "Download the book to export it": "Pobierz książkę, aby ją wyeksportować", + "Unlock Readest": "Odblokuj Readest", + "Use {{biometry}}": "Użyj {{biometry}}", + "Unlock with {{biometry}}": "Odblokuj za pomocą {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Wymaga kodu PIN (i biometrii, jeśli dostępna) do otwarcia Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometria" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 4afe0ade..56968a30 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Apaga o livro e todos os seus dados, incluindo o progresso de leitura", "More Actions": "Mais ações", "Sign in and make the book available to share it": "Faça login e tenha o livro disponível para compartilhá-lo", - "Download the book to export it": "Baixe o livro para exportá-lo" + "Download the book to export it": "Baixe o livro para exportá-lo", + "Unlock Readest": "Desbloquear o Readest", + "Use {{biometry}}": "Usar {{biometry}}", + "Unlock with {{biometry}}": "Desbloquear com {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Requer um PIN (e biometria, se disponível) para abrir o Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometria" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index be8aaced..95502876 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Apaga o livro e todos os seus dados, incluindo o progresso de leitura", "More Actions": "Mais ações", "Sign in and make the book available to share it": "Inicie sessão e tenha o livro disponível para o partilhar", - "Download the book to export it": "Transfira o livro para o exportar" + "Download the book to export it": "Transfira o livro para o exportar", + "Unlock Readest": "Desbloquear o Readest", + "Use {{biometry}}": "Usar {{biometry}}", + "Unlock with {{biometry}}": "Desbloquear com {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Requer um PIN (e biometria, se disponível) para abrir o Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometria" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index f8af5ef8..aeea7cfa 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1799,5 +1799,12 @@ "Erase the book and all its data, including reading progress": "Șterge cartea și toate datele sale, inclusiv progresul lecturii", "More Actions": "Mai multe acțiuni", "Sign in and make the book available to share it": "Conectează-te și fă cartea disponibilă pentru a o partaja", - "Download the book to export it": "Descarcă cartea pentru a o exporta" + "Download the book to export it": "Descarcă cartea pentru a o exporta", + "Unlock Readest": "Deblochează Readest", + "Use {{biometry}}": "Folosește {{biometry}}", + "Unlock with {{biometry}}": "Deblochează cu {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Necesită un PIN (și biometrie, dacă este disponibilă) pentru a deschide Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrie" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 78842e72..4cf46754 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1832,5 +1832,12 @@ "Erase the book and all its data, including reading progress": "Удаляет книгу и все её данные, включая прогресс чтения", "More Actions": "Ещё действия", "Sign in and make the book available to share it": "Войдите и сделайте книгу доступной, чтобы поделиться ею", - "Download the book to export it": "Скачайте книгу, чтобы экспортировать её" + "Download the book to export it": "Скачайте книгу, чтобы экспортировать её", + "Unlock Readest": "Разблокировать Readest", + "Use {{biometry}}": "Использовать {{biometry}}", + "Unlock with {{biometry}}": "Разблокировать с помощью {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Требуется PIN-код (и биометрия, если доступна) для открытия Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "биометрия" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 0fdd646e..64734512 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "කියවීමේ ප්‍රගතිය ඇතුළුව පොත සහ එහි සියලු දත්ත මකන්න", "More Actions": "තවත් ක්‍රියා", "Sign in and make the book available to share it": "බෙදා ගැනීමට පුරනය වී පොත ලබා ගත හැකි කරන්න", - "Download the book to export it": "අපනයනය කිරීමට පොත බාගන්න" + "Download the book to export it": "අපනයනය කිරීමට පොත බාගන්න", + "Unlock Readest": "Readest අඟුල් හරිනු", + "Use {{biometry}}": "{{biometry}} භාවිත කරන්න", + "Unlock with {{biometry}}": "{{biometry}} සමඟ අඟුල් හරිනු", + "Require a PIN (and biometrics, if available) to open Readest": "Readest විවෘත කිරීමට PIN (සහ ජෛව මිතික, ලබා ගත හැකි නම්) අවශ්‍ය වේ", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "ජෛව මිතික" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index d893a97a..ee971b20 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1832,5 +1832,12 @@ "Erase the book and all its data, including reading progress": "Izbriše knjigo in vse njene podatke, vključno z napredkom branja", "More Actions": "Več dejanj", "Sign in and make the book available to share it": "Prijavite se in omogočite knjigo za deljenje", - "Download the book to export it": "Prenesite knjigo, da jo izvozite" + "Download the book to export it": "Prenesite knjigo, da jo izvozite", + "Unlock Readest": "Odkleni Readest", + "Use {{biometry}}": "Uporabi {{biometry}}", + "Unlock with {{biometry}}": "Odkleni z {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Za odpiranje Readest je potrebna PIN-koda (in biometrija, če je na voljo)", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrija" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 26b018fe..244668d5 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Raderar boken och all data, inklusive läsförlopp", "More Actions": "Fler åtgärder", "Sign in and make the book available to share it": "Logga in och gör boken tillgänglig för att dela den", - "Download the book to export it": "Ladda ner boken för att exportera den" + "Download the book to export it": "Ladda ner boken för att exportera den", + "Unlock Readest": "Lås upp Readest", + "Use {{biometry}}": "Använd {{biometry}}", + "Unlock with {{biometry}}": "Lås upp med {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Kräver en PIN-kod (och biometri, om tillgängligt) för att öppna Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometri" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index e706259a..fbd4eec0 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "வாசிப்பு முன்னேற்றம் உட்பட புத்தகத்தையும் அதன் எல்லா தரவையும் அழிக்கும்", "More Actions": "மேலும் செயல்கள்", "Sign in and make the book available to share it": "பகிர உள்நுழைந்து புத்தகத்தைக் கிடைக்கச் செய்யவும்", - "Download the book to export it": "ஏற்றுமதி செய்ய புத்தகத்தைப் பதிவிறக்கவும்" + "Download the book to export it": "ஏற்றுமதி செய்ய புத்தகத்தைப் பதிவிறக்கவும்", + "Unlock Readest": "Readest-ஐ திறக்கவும்", + "Use {{biometry}}": "{{biometry}} பயன்படுத்தவும்", + "Unlock with {{biometry}}": "{{biometry}} மூலம் திறக்கவும்", + "Require a PIN (and biometrics, if available) to open Readest": "Readest திறக்க PIN (மற்றும் கிடைத்தால் உயிரியல் அடையாளம்) தேவை", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "உயிரியல் அடையாளம்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index c1fc8759..89a79a0f 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "ลบหนังสือและข้อมูลทั้งหมด รวมถึงความคืบหน้าการอ่าน", "More Actions": "การทำงานเพิ่มเติม", "Sign in and make the book available to share it": "ลงชื่อเข้าใช้และทำให้หนังสือพร้อมใช้งานเพื่อแชร์", - "Download the book to export it": "ดาวน์โหลดหนังสือเพื่อส่งออก" + "Download the book to export it": "ดาวน์โหลดหนังสือเพื่อส่งออก", + "Unlock Readest": "ปลดล็อก Readest", + "Use {{biometry}}": "ใช้ {{biometry}}", + "Unlock with {{biometry}}": "ปลดล็อกด้วย {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "ต้องใช้ PIN (และข้อมูลไบโอเมตริก หากมี) เพื่อเปิด Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "ข้อมูลไบโอเมตริก" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 36f9acea..dd1cd863 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Kitabı ve okuma ilerlemesi dahil tüm verilerini siler", "More Actions": "Diğer eylemler", "Sign in and make the book available to share it": "Paylaşmak için oturum açın ve kitabı kullanılabilir hale getirin", - "Download the book to export it": "Dışa aktarmak için kitabı indirin" + "Download the book to export it": "Dışa aktarmak için kitabı indirin", + "Unlock Readest": "Readest'i Kilidi Aç", + "Use {{biometry}}": "{{biometry}} Kullan", + "Unlock with {{biometry}}": "{{biometry}} ile Kilidi Aç", + "Require a PIN (and biometrics, if available) to open Readest": "Readest'i açmak için PIN (ve varsa biyometri) gereklidir", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biyometri" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 0cc5c895..b24345a9 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1832,5 +1832,12 @@ "Erase the book and all its data, including reading progress": "Видаляє книгу та всі її дані, зокрема прогрес читання", "More Actions": "Більше дій", "Sign in and make the book available to share it": "Увійдіть і зробіть книгу доступною, щоб поділитися нею", - "Download the book to export it": "Завантажте книгу, щоб експортувати її" + "Download the book to export it": "Завантажте книгу, щоб експортувати її", + "Unlock Readest": "Розблокувати Readest", + "Use {{biometry}}": "Використати {{biometry}}", + "Unlock with {{biometry}}": "Розблокувати за допомогою {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Потрібен PIN-код (і біометрія, якщо доступна) для відкриття Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "біометрія" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 83168645..1768ba94 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1766,5 +1766,12 @@ "Erase the book and all its data, including reading progress": "Kitobni va uning barcha maʼlumotlarini, jumladan oʻqish jarayonini oʻchiradi", "More Actions": "Qoʻshimcha amallar", "Sign in and make the book available to share it": "Ulashish uchun tizimga kiring va kitobni mavjud qiling", - "Download the book to export it": "Eksport qilish uchun kitobni yuklab oling" + "Download the book to export it": "Eksport qilish uchun kitobni yuklab oling", + "Unlock Readest": "Readest-ni ochish", + "Use {{biometry}}": "{{biometry}} dan foydalanish", + "Unlock with {{biometry}}": "{{biometry}} bilan ochish", + "Require a PIN (and biometrics, if available) to open Readest": "Readest-ni ochish uchun PIN (va mavjud bo'lsa, biometrik ma'lumotlar) talab qilinadi", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "biometrik" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index c4612656..79062cb4 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "Xóa sách và toàn bộ dữ liệu, bao gồm tiến độ đọc", "More Actions": "Thao tác khác", "Sign in and make the book available to share it": "Đăng nhập và tải sách về để chia sẻ", - "Download the book to export it": "Tải sách về để xuất ra" + "Download the book to export it": "Tải sách về để xuất ra", + "Unlock Readest": "Mở khóa Readest", + "Use {{biometry}}": "Sử dụng {{biometry}}", + "Unlock with {{biometry}}": "Mở khóa bằng {{biometry}}", + "Require a PIN (and biometrics, if available) to open Readest": "Yêu cầu mã PIN (và sinh trắc học, nếu có) để mở Readest", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "sinh trắc học" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 820eff27..784d142f 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "删除该书籍及其所有数据,包括阅读进度", "More Actions": "更多操作", "Sign in and make the book available to share it": "登录并使书籍可用后即可分享", - "Download the book to export it": "下载书籍后即可导出" + "Download the book to export it": "下载书籍后即可导出", + "Unlock Readest": "解锁 Readest", + "Use {{biometry}}": "使用{{biometry}}", + "Unlock with {{biometry}}": "通过{{biometry}}解锁", + "Require a PIN (and biometrics, if available) to open Readest": "打开 Readest 需要 PIN 码(如可用,也支持生物识别)", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "生物识别" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index c552d740..e0ae7618 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1733,5 +1733,12 @@ "Erase the book and all its data, including reading progress": "刪除該書籍及其所有資料,包括閱讀進度", "More Actions": "更多操作", "Sign in and make the book available to share it": "登入並使書籍可用後即可分享", - "Download the book to export it": "下載書籍後即可匯出" + "Download the book to export it": "下載書籍後即可匯出", + "Unlock Readest": "解鎖 Readest", + "Use {{biometry}}": "使用{{biometry}}", + "Unlock with {{biometry}}": "透過{{biometry}}解鎖", + "Require a PIN (and biometrics, if available) to open Readest": "開啟 Readest 需要 PIN 碼(如可用,亦支援生物辨識)", + "Face ID": "Face ID", + "Touch ID": "Touch ID", + "biometrics": "生物辨識" } diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index b3c76e7a..58de1607 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" tauri-plugin-sign-in-with-apple = "1.0.2" tauri-plugin-haptics = "2" +tauri-plugin-biometric = "2" tauri-plugin-persisted-scope = "2" tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" } tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" } diff --git a/apps/readest-app/src-tauri/Info.plist b/apps/readest-app/src-tauri/Info.plist index bc00a3e0..555429ea 100644 --- a/apps/readest-app/src-tauri/Info.plist +++ b/apps/readest-app/src-tauri/Info.plist @@ -4,6 +4,8 @@ ITSAppUsesNonExemptEncryption + NSFaceIDUsageDescription + Readest uses Face ID to unlock the app. LSSupportsOpeningDocumentsInPlace UIViewControllerBasedStatusBarAppearance diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 2adb844f..127dd322 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -190,6 +190,7 @@ } ] }, + "biometric:default", "haptics:allow-vibrate", "haptics:allow-impact-feedback", "haptics:allow-notification-feedback", diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 07342384..591ee125 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -359,6 +359,9 @@ pub fn run() { #[cfg(any(target_os = "ios", target_os = "android"))] let builder = builder.plugin(tauri_plugin_haptics::init()); + #[cfg(any(target_os = "ios", target_os = "android"))] + let builder = builder.plugin(tauri_plugin_biometric::init()); + #[cfg(feature = "webdriver")] let builder = builder.plugin(tauri_plugin_webdriver::init()); diff --git a/apps/readest-app/src/__tests__/components/AppLockScreen.test.tsx b/apps/readest-app/src/__tests__/components/AppLockScreen.test.tsx new file mode 100644 index 00000000..b600112b --- /dev/null +++ b/apps/readest-app/src/__tests__/components/AppLockScreen.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; + +const unlockMock = vi.fn(); +const authenticateWithBiometricsMock = vi.fn(); +const getBiometricStatusMock = vi.fn(); +let isSupported = true; +let biometricUnlockEnabled = true; +let isMobileApp = true; + +vi.mock('@/services/biometric', async () => { + const actual = + await vi.importActual('@/services/biometric'); + return { + ...actual, + isBiometricSupported: () => isSupported, + getBiometricStatus: () => getBiometricStatusMock(), + authenticateWithBiometrics: (...a: unknown[]) => authenticateWithBiometricsMock(...a), + }; +}); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: { isMobile: isMobileApp, isMobileApp } }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (k: string) => k })); + +vi.mock('@/store/appLockStore', () => ({ + useAppLockStore: () => ({ + pinHash: 'h', + pinSalt: 's', + unlock: unlockMock, + biometricUnlockEnabled, + }), +})); + +vi.mock('@/components/PinInput', () => ({ + __esModule: true, + default: ({ ariaLabel }: { ariaLabel: string }) => , +})); + +import AppLockScreen from '@/components/AppLockScreen'; + +beforeEach(() => { + unlockMock.mockReset(); + authenticateWithBiometricsMock.mockReset(); + getBiometricStatusMock.mockReset(); + getBiometricStatusMock.mockResolvedValue({ available: true, biometryType: 2 }); + isSupported = true; + biometricUnlockEnabled = true; + isMobileApp = true; +}); +afterEach(cleanup); + +describe('AppLockScreen biometric gate', () => { + it('auto-unlocks when biometric authentication succeeds on mount', async () => { + authenticateWithBiometricsMock.mockResolvedValue(true); + render(); + await waitFor(() => expect(unlockMock).toHaveBeenCalledTimes(1)); + }); + + it('shows a retry button and stays locked when biometric fails', async () => { + authenticateWithBiometricsMock.mockResolvedValue(false); + render(); + await waitFor(() => expect(authenticateWithBiometricsMock).toHaveBeenCalledTimes(1)); + expect(unlockMock).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /Use/ })).toBeTruthy(); + expect(screen.getByLabelText('PIN code')).toBeTruthy(); + }); + + it('never calls biometric when unsupported (desktop/web)', async () => { + isSupported = false; + render(); + await new Promise((r) => setTimeout(r, 50)); + expect(getBiometricStatusMock).not.toHaveBeenCalled(); + expect(authenticateWithBiometricsMock).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: /Use/ })).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/biometric.test.ts b/apps/readest-app/src/__tests__/services/biometric.test.ts new file mode 100644 index 00000000..ef7145fa --- /dev/null +++ b/apps/readest-app/src/__tests__/services/biometric.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +const authenticateMock = vi.fn(); +const checkStatusMock = vi.fn(); + +vi.mock('@tauri-apps/plugin-biometric', () => ({ + authenticate: (...args: unknown[]) => authenticateMock(...args), + checkStatus: (...args: unknown[]) => checkStatusMock(...args), + BiometryType: { None: 0, TouchID: 1, FaceID: 2, Iris: 3 }, +})); + +import { BiometryType } from '@tauri-apps/plugin-biometric'; +import { + authenticateWithBiometrics, + defaultBiometricUnlockOnPinSet, + getBiometricStatus, + getBiometryLabelKey, + isBiometricSupported, + shouldAttemptBiometricUnlock, +} from '@/services/biometric'; + +const mobile = (over: Partial<{ isIOSApp: boolean; isAndroidApp: boolean }> = {}) => + ({ isIOSApp: false, isAndroidApp: true, ...over }) as never; + +beforeEach(() => { + authenticateMock.mockReset(); + checkStatusMock.mockReset(); +}); + +describe('isBiometricSupported', () => { + test('true on iOS/Android app, false otherwise and for null', () => { + expect(isBiometricSupported(mobile({ isIOSApp: true, isAndroidApp: false }))).toBe(true); + expect(isBiometricSupported(mobile({ isAndroidApp: true }))).toBe(true); + expect(isBiometricSupported({ isIOSApp: false, isAndroidApp: false } as never)).toBe(false); + expect(isBiometricSupported(null)).toBe(false); + }); +}); + +describe('shouldAttemptBiometricUnlock', () => { + test('requires all three conditions', () => { + expect( + shouldAttemptBiometricUnlock({ + isMobileApp: true, + biometricUnlockEnabled: true, + available: true, + }), + ).toBe(true); + expect( + shouldAttemptBiometricUnlock({ + isMobileApp: false, + biometricUnlockEnabled: true, + available: true, + }), + ).toBe(false); + expect( + shouldAttemptBiometricUnlock({ + isMobileApp: true, + biometricUnlockEnabled: false, + available: true, + }), + ).toBe(false); + expect( + shouldAttemptBiometricUnlock({ + isMobileApp: true, + biometricUnlockEnabled: true, + available: false, + }), + ).toBe(false); + }); +}); + +describe('defaultBiometricUnlockOnPinSet', () => { + test('on only when mobile and available', () => { + expect(defaultBiometricUnlockOnPinSet({ isMobileApp: true, available: true })).toBe(true); + expect(defaultBiometricUnlockOnPinSet({ isMobileApp: true, available: false })).toBe(false); + expect(defaultBiometricUnlockOnPinSet({ isMobileApp: false, available: true })).toBe(false); + }); +}); + +describe('getBiometryLabelKey', () => { + test('maps biometry type to a label key', () => { + expect(getBiometryLabelKey(BiometryType.FaceID)).toBe('Face ID'); + expect(getBiometryLabelKey(BiometryType.TouchID)).toBe('Touch ID'); + expect(getBiometryLabelKey(BiometryType.None)).toBe('biometrics'); + expect(getBiometryLabelKey(BiometryType.Iris)).toBe('biometrics'); + }); +}); + +describe('getBiometricStatus', () => { + test('returns availability from checkStatus', async () => { + checkStatusMock.mockResolvedValue({ isAvailable: true, biometryType: BiometryType.FaceID }); + await expect(getBiometricStatus()).resolves.toEqual({ + available: true, + biometryType: BiometryType.FaceID, + }); + }); + + test('returns available:false when checkStatus resolves with isAvailable false', async () => { + checkStatusMock.mockResolvedValue({ isAvailable: false, biometryType: BiometryType.None }); + await expect(getBiometricStatus()).resolves.toEqual({ + available: false, + biometryType: BiometryType.None, + }); + }); + + test('returns unavailable when checkStatus throws', async () => { + checkStatusMock.mockRejectedValue(new Error('no plugin')); + await expect(getBiometricStatus()).resolves.toEqual({ + available: false, + biometryType: BiometryType.None, + }); + }); +}); + +describe('authenticateWithBiometrics', () => { + test('true when authenticate resolves', async () => { + authenticateMock.mockResolvedValue(undefined); + await expect(authenticateWithBiometrics('Unlock')).resolves.toBe(true); + expect(authenticateMock).toHaveBeenCalledWith( + 'Unlock', + expect.objectContaining({ allowDeviceCredential: false }), + ); + }); + + test('false when authenticate throws (cancel/fail)', async () => { + authenticateMock.mockRejectedValue(new Error('userCancel')); + await expect(authenticateWithBiometrics('Unlock')).resolves.toBe(false); + expect(authenticateMock).toHaveBeenCalledWith( + 'Unlock', + expect.objectContaining({ allowDeviceCredential: false }), + ); + }); +}); diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 0df1f6fe..f81846cb 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi'; import { PiSun, PiMoon } from 'react-icons/pi'; @@ -26,6 +26,11 @@ import { setAboutDialogVisible } from '@/components/AboutWindow'; import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow'; import { requestStoragePermission } from '@/utils/permission'; import { saveSysSettings } from '@/helpers/settings'; +import { + getBiometricStatus, + getBiometryLabelKey, + isBiometricSupported, +} from '@/services/biometric'; import { selectDirectory } from '@/utils/bridge'; import dayjs from 'dayjs'; import UserAvatar from '@/components/UserAvatar'; @@ -69,6 +74,26 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const [refreshMetadataProgress, setRefreshMetadataProgress] = useState(''); const { openDialog: openAppLockDialogInStore } = useAppLockStore(); const isPinEnabled = !!settings.pinCodeEnabled; + const [biometricAvailable, setBiometricAvailable] = useState(false); + const [biometryLabelKey, setBiometryLabelKey] = useState(''); + const showBiometricToggle = !!appService?.isMobileApp && isPinEnabled && biometricAvailable; + + useEffect(() => { + if (!isBiometricSupported(appService) || !isPinEnabled) return; + let cancelled = false; + void getBiometricStatus().then(({ available, biometryType }) => { + if (cancelled) return; + setBiometricAvailable(available); + setBiometryLabelKey(getBiometryLabelKey(biometryType)); + }); + return () => { + cancelled = true; + }; + }, [appService, isPinEnabled]); + + const toggleBiometricUnlock = () => { + void saveSysSettings(envConfig, 'biometricUnlockEnabled', !settings.biometricUnlockEnabled); + }; const openAppLockDialog = (mode: AppLockDialogMode) => { openAppLockDialogInStore(mode); @@ -417,7 +442,11 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow {!isPinEnabled && ( openAppLockDialog('set')} /> )} @@ -427,6 +456,13 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow {isPinEnabled && ( openAppLockDialog('disable')} /> )} + {showBiometricToggle && ( + + )} {appService?.isAndroidApp && appService?.distChannel !== 'playstore' && ( { + if (biometricInFlightRef.current) return; + biometricInFlightRef.current = true; + try { + const ok = await authenticateWithBiometrics(_('Unlock Readest')); + if (ok) unlock(); + } finally { + biometricInFlightRef.current = false; + } + }; + + useEffect(() => { + if (!isBiometricSupported(appService)) return; + let cancelled = false; + void (async () => { + const { available, biometryType } = await getBiometricStatus(); + if (cancelled) return; + if ( + !shouldAttemptBiometricUnlock({ + isMobileApp: !!appService?.isMobileApp, + biometricUnlockEnabled, + available, + }) + ) { + return; + } + setBiometryLabel(_(getBiometryLabelKey(biometryType))); + if (biometricAttemptedRef.current) return; + biometricAttemptedRef.current = true; + await runBiometric(); + })(); + return () => { + cancelled = true; + }; + }, [appService]); useEffect(() => { const vv = window.visualViewport; @@ -102,6 +148,20 @@ export default function AppLockScreen() { {error || ' '}

+ {biometryLabel && ( + + )} +

{_( "Forgetting your PIN locks you out of this device. You'll need to clear the app's data to reset it.", diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index a3ee1320..b789f48d 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -169,6 +169,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => { enabled: !!settings.pinCodeEnabled, hash: settings.pinCodeHash, salt: settings.pinCodeSalt, + biometricUnlockEnabled: !!settings.biometricUnlockEnabled, }); // Subscribe the bundled-settings publisher to settingsStore // changes, AFTER priming the publish snapshot from the just- diff --git a/apps/readest-app/src/components/settings/AppLockDialog.tsx b/apps/readest-app/src/components/settings/AppLockDialog.tsx index 1d6633a1..9bb5c2aa 100644 --- a/apps/readest-app/src/components/settings/AppLockDialog.tsx +++ b/apps/readest-app/src/components/settings/AppLockDialog.tsx @@ -9,6 +9,11 @@ import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { saveSysSettings } from '@/helpers/settings'; import { PIN_LENGTH, generatePinSalt, hashPin, isValidPin, verifyPin } from '@/libs/crypto/applock'; +import { + defaultBiometricUnlockOnPinSet, + getBiometricStatus, + isBiometricSupported, +} from '@/services/biometric'; import { useAppLockStore } from '@/store/appLockStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -23,7 +28,7 @@ const fieldLabelClass = 'text-base-content/70 text-xs font-medium tracking-wide' */ export default function AppLockDialog() { const _ = useTranslation(); - const { envConfig } = useEnv(); + const { envConfig, appService } = useEnv(); const { settings } = useSettingsStore(); const { pinHash, @@ -95,6 +100,14 @@ export default function AppLockDialog() { await saveSysSettings(envConfig, 'pinCodeSalt', salt); await saveSysSettings(envConfig, 'pinCodeHash', hash); await saveSysSettings(envConfig, 'pinCodeEnabled', true); + if (isBiometricSupported(appService)) { + const { available } = await getBiometricStatus(); + await saveSysSettings( + envConfig, + 'biometricUnlockEnabled', + defaultBiometricUnlockOnPinSet({ isMobileApp: !!appService?.isMobileApp, available }), + ); + } setStorePin(hash, salt); closeDialog(); } finally { @@ -162,6 +175,7 @@ export default function AppLockDialog() { await saveSysSettings(envConfig, 'pinCodeEnabled', false); await saveSysSettings(envConfig, 'pinCodeHash', undefined); await saveSysSettings(envConfig, 'pinCodeSalt', undefined); + await saveSysSettings(envConfig, 'biometricUnlockEnabled', undefined); clearPin(); closeDialog(); } finally { diff --git a/apps/readest-app/src/services/biometric.ts b/apps/readest-app/src/services/biometric.ts new file mode 100644 index 00000000..4848124f --- /dev/null +++ b/apps/readest-app/src/services/biometric.ts @@ -0,0 +1,59 @@ +import { authenticate, checkStatus, BiometryType } from '@tauri-apps/plugin-biometric'; + +import type { AppService } from '@/types/system'; +import { stubTranslation as _ } from '@/utils/misc'; + +/** + * Everything biometric lives behind this wrapper so the rest of the app + * never imports `@tauri-apps/plugin-biometric` directly. Off mobile-Tauri + * the plugin commands are never reached (callers gate on + * `isBiometricSupported`), and the async wrappers swallow errors so a + * missing plugin can never throw into the UI. + */ + +export const isBiometricSupported = (appService: AppService | null): boolean => + !!(appService?.isIOSApp || appService?.isAndroidApp); + +export const getBiometricStatus = async (): Promise<{ + available: boolean; + biometryType: BiometryType; +}> => { + try { + const status = await checkStatus(); + return { available: status.isAvailable, biometryType: status.biometryType }; + } catch { + return { available: false, biometryType: BiometryType.None }; + } +}; + +export const authenticateWithBiometrics = async (reason: string): Promise => { + try { + await authenticate(reason, { allowDeviceCredential: false }); + return true; + } catch { + // Cancel, no-match, lockout, or unavailable — fall back to the PIN. + return false; + } +}; + +export const shouldAttemptBiometricUnlock = (opts: { + isMobileApp: boolean; + biometricUnlockEnabled: boolean; + available: boolean; +}): boolean => opts.isMobileApp && opts.biometricUnlockEnabled && opts.available; + +export const defaultBiometricUnlockOnPinSet = (opts: { + isMobileApp: boolean; + available: boolean; +}): boolean => opts.isMobileApp && opts.available; + +/** + * i18n key for the biometry name. Returned key is fed back through `_()` + * in the component (key-as-content i18n); `stubTranslation` here just + * registers the strings for extraction. + */ +export const getBiometryLabelKey = (biometryType: BiometryType): string => { + if (biometryType === BiometryType.FaceID) return _('Face ID'); + if (biometryType === BiometryType.TouchID) return _('Touch ID'); + return _('biometrics'); +}; diff --git a/apps/readest-app/src/store/appLockStore.ts b/apps/readest-app/src/store/appLockStore.ts index 3a4e4ee4..46c456e5 100644 --- a/apps/readest-app/src/store/appLockStore.ts +++ b/apps/readest-app/src/store/appLockStore.ts @@ -23,8 +23,20 @@ interface AppLockState { pinHash: string | null; pinSalt: string | null; + /** + * Startup snapshot of `SystemSettings.biometricUnlockEnabled` (mobile-only). + * Threaded through `initialize` so `AppLockScreen` never races the page-level + * settingsStore seed. + */ + biometricUnlockEnabled: boolean; + /** Called once from `Providers` after `loadSettings` resolves. */ - initialize: (config: { enabled: boolean; hash?: string; salt?: string }) => void; + initialize: (config: { + enabled: boolean; + hash?: string; + salt?: string; + biometricUnlockEnabled?: boolean; + }) => void; /** Called by `` after a verified PIN entry. */ unlock: () => void; @@ -53,12 +65,14 @@ export const useAppLockStore = create((set) => ({ isUnlocked: true, pinHash: null, pinSalt: null, - initialize: ({ enabled, hash, salt }) => + biometricUnlockEnabled: false, + initialize: ({ enabled, hash, salt, biometricUnlockEnabled }) => set({ isInitialized: true, isUnlocked: !enabled, pinHash: hash ?? null, pinSalt: salt ?? null, + biometricUnlockEnabled: !!biometricUnlockEnabled, }), unlock: () => set({ isUnlocked: true }), lock: () => set({ isUnlocked: false }), diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 384cecfa..c613b8ff 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -346,6 +346,14 @@ export interface SystemSettings { pinCodeEnabled?: boolean; pinCodeHash?: string; pinCodeSalt?: string; + /** + * Mobile-only. When true AND a PIN lock is configured AND the device + * has enrolled biometrics, the app-lock screen prompts for biometrics + * (fingerprint / Face ID) first and falls back to the PIN. No effect on + * desktop/web (no biometric plugin). `undefined` is treated as `false` + * so existing PIN users are never silently switched to biometric. + */ + biometricUnlockEnabled?: boolean; kosync: KOSyncSettings; readwise: ReadwiseSettings; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 938ef26f..64585286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@tauri-apps/api': specifier: 2.10.1 version: 2.10.1 + '@tauri-apps/plugin-biometric': + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-cli': specifier: ^2.4.1 version: 2.4.1 @@ -3304,6 +3307,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-biometric@2.3.2': + resolution: {integrity: sha512-RFydYGKEJ6m4UWdcPsO+vJQUb3JyyfFeNoZSPDs0uWjvwHBs03xey5wWO+uowmFFy03JAff1l2idQAwuycO8Uw==} + '@tauri-apps/plugin-cli@2.4.1': resolution: {integrity: sha512-8JXofQFI5cmiGolh1PlU4hzE2YJgrgB1lyaztyBYiiMCy13luVxBXaXChYPeqMkUo46J1UadxvYdjRjj0E8zaw==} @@ -11655,6 +11661,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-biometric@2.3.2': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-cli@2.4.1': dependencies: '@tauri-apps/api': 2.10.1