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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(applock): mobile biometric toggle + default-on at PIN setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* build(applock): pin tauri-plugin-biometric in Cargo.lock

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 00:39:03 +08:00
committed by GitHub
parent af587b1a41
commit 6c7c86f346
49 changed files with 709 additions and 40 deletions
Generated
+16
View File
@@ -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"
+1
View File
@@ -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",
@@ -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": "البيومترية"
}
@@ -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": "বায়োমেট্রিক্স"
}
@@ -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": "ལུས་རྟགས་ངོ་སྤྲོད།"
}
@@ -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"
}
@@ -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": "βιομετρικά"
}
@@ -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"
}
@@ -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": "بیومتریک"
}
@@ -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"
}
@@ -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": "ביומטריה"
}
@@ -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": "बायोमेट्रिक्स"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "生体認証"
}
@@ -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": "생체 인증"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "биометрия"
}
@@ -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": "ජෛව මිතික"
}
@@ -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"
}
@@ -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"
}
@@ -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": "உயிரியல் அடையாளம்"
}
@@ -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": "ข้อมูลไบโอเมตริก"
}
@@ -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"
}
@@ -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": "біометрія"
}
@@ -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"
}
@@ -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"
}
@@ -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": "生物识别"
}
@@ -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": "生物辨識"
}
+1
View File
@@ -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" }
+2
View File
@@ -4,6 +4,8 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSFaceIDUsageDescription</key>
<string>Readest uses Face ID to unlock the app.</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>UIViewControllerBasedStatusBarAppearance</key>
@@ -190,6 +190,7 @@
}
]
},
"biometric:default",
"haptics:allow-vibrate",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
+3
View File
@@ -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());
@@ -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<typeof import('@/services/biometric')>('@/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 }) => <input aria-label={ariaLabel} />,
}));
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(<AppLockScreen />);
await waitFor(() => expect(unlockMock).toHaveBeenCalledTimes(1));
});
it('shows a retry button and stays locked when biometric fails', async () => {
authenticateWithBiometricsMock.mockResolvedValue(false);
render(<AppLockScreen />);
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(<AppLockScreen />);
await new Promise((r) => setTimeout(r, 50));
expect(getBiometricStatusMock).not.toHaveBeenCalled();
expect(authenticateWithBiometricsMock).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: /Use/ })).toBeNull();
});
});
@@ -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 }),
);
});
});
@@ -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<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
{!isPinEnabled && (
<MenuItem
label={_('Set PIN…')}
tooltip={_('Require a 4-digit PIN to open Readest')}
tooltip={
appService?.isMobileApp
? _('Require a PIN (and biometrics, if available) to open Readest')
: _('Require a 4-digit PIN to open Readest')
}
onClick={() => openAppLockDialog('set')}
/>
)}
@@ -427,6 +456,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
{isPinEnabled && (
<MenuItem label={_('Disable PIN…')} onClick={() => openAppLockDialog('disable')} />
)}
{showBiometricToggle && (
<MenuItem
label={_('Unlock with {{biometry}}', { biometry: _(biometryLabelKey) })}
toggled={!!settings.biometricUnlockEnabled}
onClick={toggleBiometricUnlock}
/>
)}
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
<MenuItem
label={_('Save Book Cover')}
@@ -8,16 +8,62 @@ import { useEnv } from '@/context/EnvContext';
import { PIN_LENGTH, verifyPin } from '@/libs/crypto/applock';
import { useAppLockStore } from '@/store/appLockStore';
import { useTranslation } from '@/hooks/useTranslation';
import {
authenticateWithBiometrics,
getBiometricStatus,
getBiometryLabelKey,
isBiometricSupported,
shouldAttemptBiometricUnlock,
} from '@/services/biometric';
export default function AppLockScreen() {
const _ = useTranslation();
const { appService } = useEnv();
const { pinHash, pinSalt, unlock } = useAppLockStore();
const autoFocusEnabled = !appService?.isMobile;
const { pinHash, pinSalt, unlock, biometricUnlockEnabled } = useAppLockStore();
const [biometryLabel, setBiometryLabel] = useState('');
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [shaking, setShaking] = useState(false);
const [kbInset, setKbInset] = useState(0);
const biometricAttemptedRef = useRef(false);
const biometricInFlightRef = useRef(false);
const autoFocusEnabled = !appService?.isMobile;
const runBiometric = async () => {
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 || ' '}
</p>
{biometryLabel && (
<button
type='button'
onClick={runBiometric}
className={clsx(
'eink-bordered',
'mt-2 h-10 rounded-lg px-4 text-sm font-medium',
'text-base-content hover:bg-base-200 transition-colors duration-150',
)}
>
{_('Use {{biometry}}', { biometry: biometryLabel })}
</button>
)}
<p className='text-base-content/40 mt-10 text-xs leading-relaxed'>
{_(
"Forgetting your PIN locks you out of this device. You'll need to clear the app's data to reset it.",
@@ -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-
@@ -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 {
@@ -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<boolean> => {
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');
};
+16 -2
View File
@@ -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 `<AppLockScreen />` after a verified PIN entry. */
unlock: () => void;
@@ -53,12 +65,14 @@ export const useAppLockStore = create<AppLockState>((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 }),
+8
View File
@@ -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;
+10
View File
@@ -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