Compare commits

...

51 Commits

Author SHA1 Message Date
Huang Xin f4908c4536 release: version 0.9.31 (#839) 2025-04-08 15:54:34 +02:00
Huang Xin 37951c2ebf feat: add book metadata search in the bookshelf, closes #100 (#838) 2025-04-08 15:37:18 +02:00
Huang Xin 915f222e08 doc: add a troubleshooting section in README (#837) 2025-04-08 18:05:22 +08:00
Huang Xin 9446df1b9c feat: add an option to configure default CJK fonts in CJK environments, closes #780 (#836) 2025-04-08 11:27:59 +02:00
Huang Xin bfcf89b093 fix: toggle the menus on the whole screen when Click-To-Flip is disabled, closes #831 (#834) 2025-04-08 08:09:46 +02:00
Huang Xin b0cf087d78 fix: support opening content URIs by copying to cache when direct access fails, closes #829 (#833) 2025-04-08 07:45:33 +02:00
Huang Xin 5863586766 feat: vertical layout for highlighting tools, closes #787 (#830) 2025-04-07 17:24:14 +02:00
Huang Xin 99182a1f9e feat: add configurable timeout to stop TTS playback automatically, closes #747 (#826) 2025-04-07 12:40:14 +02:00
Huang Xin 2599f40dfb feat: add option to keep window on top, closes #820 (#825) 2025-04-07 08:23:46 +02:00
Huang Xin aa66ba54c5 fix: set proper default theme mode, closes #823 (#824) 2025-04-07 07:16:31 +02:00
Huang Xin 4f0ef01a17 fix: tts now works in background in iOS, closes #547 (#822)
To enable background playback in Android, go to Settings > Apps & Notifications > Readest > Battery > Battery Optimization, and disable battery optimization for Readest.
2025-04-06 18:42:58 +02:00
Huang Xin 267e1656db release: version 0.9.30 (#817) 2025-04-05 11:39:37 +02:00
Huang Xin dcecda2984 fix: cache NativeFile with LRU, closes #797 (#816) 2025-04-05 11:27:07 +02:00
Huang Xin ce88f0229a fix: rounded reader widget only when sidebar is invisible , closes #809 (#815) 2025-04-05 10:08:39 +02:00
Huang Xin 316c82ea52 fix: override link color only when overriding font, closes #812 (#814) 2025-04-05 09:34:29 +02:00
Huang Xin 25be60ce2a chore: bump tauri to 2.4.1 (#813) 2025-04-05 07:40:33 +02:00
Huang Xin 4703eec045 fix: set correct filename in openFile API, closes #803 (#810) 2025-04-05 12:41:26 +08:00
Huang Xin ab22a83332 fix: open default file chooser on Android, closes #798 and closes #799 (#807) 2025-04-04 05:01:12 +02:00
Huang Xin a3d6ebe44d feat: add fixed storage quota for self-hosted instance, closes #805 (#806) 2025-04-04 03:48:43 +02:00
Huang Xin f8804b5d3c fix: revert font family, size and color when overrideFont is set, closes #792 (#804) 2025-04-03 17:15:19 +02:00
Huang Xin ee3785ad51 fix: hack to decode incorrectly encoded url in href (#796) 2025-04-02 07:35:00 +02:00
Huang Xin 5ba40cd2d6 release: version 0.9.29 (#790) 2025-04-01 18:27:15 +02:00
Huang Xin 6131180a31 fix: initiate OAuth using Custom Tabs on Android, closes #361 (#788) 2025-04-01 17:04:17 +02:00
Huang Xin 7ccf3d0632 refactor: read local file with fs plugin for Tauri App, closes #553 and closes #489 (#785) 2025-04-01 14:26:26 +02:00
Huang Xin 39fb7f759e fix: handle preflight on tauri apps (#776) 2025-03-31 03:38:47 +02:00
Huang Xin d89834a53d chore: balance translator load between auth and guest users (#771) 2025-03-30 16:35:13 +02:00
Huang Xin 3c4dd2a143 fix: fix translation api with retry (#770) 2025-03-30 15:59:57 +02:00
Huang Xin 847efb7082 fix: head bar may hide login buttons in auth page (#769) 2025-03-30 11:32:57 +02:00
Huang Xin 01db8f90fa fix: store the filepath for transient import, closes #760 (#768) 2025-03-30 10:27:45 +02:00
Huang Xin e70c46ff02 fix: only replace quote punctuations for vertical layout, closes #761 (#767) 2025-03-30 09:49:22 +02:00
Huang Xin 7cb1998ebb fix: soft keyboard in custom css textarea, closes #762 and closes #763 (#766) 2025-03-30 09:33:27 +02:00
Huang Xin f5fd37f40d release: version 0.9.28 (hotfix) (#759) 2025-03-29 19:46:09 +01:00
Huang Xin 2daee62b80 fix: dismiss highlighting tools for annotation taking (#758) 2025-03-29 19:40:37 +01:00
Huang Xin ffd179bb06 fix: TXT file correctly read and imported on desktop apps, closes #756 (#757) 2025-03-29 19:11:41 +01:00
Huang Xin 4acc0d8e12 feat: replace punctuation marks in vertical reading mode (#754) 2025-03-29 12:42:23 +01:00
Huang Xin a66786444f release: version 0.9.27 (#751) 2025-03-29 08:03:38 +01:00
Huang Xin 01ad18ca57 feat: global fulltext search shortcut with ctrl/cmd+f (#750) 2025-03-29 07:45:41 +01:00
Huang Xin a72c8f2391 refactor: rename plugin safari-auth to native-bridge (#749)
* fix: layout tweaks for mobile

* refactor: rename plugin safari-auth to native-bridge
2025-03-29 07:27:18 +01:00
Huang Xin f90177713a tts: avoid pauses after abbreviations, faster warming up, preferred voice ids (#742) 2025-03-28 13:25:08 +01:00
Huang Xin 041eb1b921 feat: support more languages in deepl translator (#740) 2025-03-28 07:31:34 +01:00
Huang Xin 105302d241 fix: don't crash with some malformed toc data for PDF, closes #518 (#739) 2025-03-28 06:54:32 +01:00
Huang Xin bd9c7b459c fix: avoid stale values in relocate handler, closes #736 (#738) 2025-03-28 06:33:51 +01:00
Huang Xin 1c122752bb fix: update downloadedAt field properly (#737) 2025-03-28 06:03:18 +01:00
Huang Xin 203ef8b986 feat: add reset password page, closes #700 (#731) 2025-03-28 03:52:04 +08:00
Huang Xin ceda806f77 fix: override book writing-mode (#730) 2025-03-27 15:53:35 +01:00
Huang Xin baac9dfa25 fix: unhardcode background color in non-default themes (#729) 2025-03-27 13:05:03 +01:00
Huang Xin 10aace0026 fix: avoid view glitch when initialize view settings, closes #713 (#728) 2025-03-27 12:40:41 +01:00
Huang Xin 3e38896078 feat: add option to swap click-to-flip area, closes #719 (#727) 2025-03-27 11:35:57 +01:00
Huang Xin 0fa53c5f15 fix: should listen single-instance event on Windows to open-with files, closes #724 (#726) 2025-03-27 09:11:03 +01:00
Xi Gou 2b6320b880 refactor: support both r2 and s3 storage (#718) 2025-03-27 07:00:27 +01:00
Huang Xin 7ba938ea25 fix: header layout for auth and user pages (#725) 2025-03-27 05:14:38 +01:00
150 changed files with 3895 additions and 1917 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: cargo fmt --check
- name: Clippy Check
working-directory: apps/readest-app/src-tauri
run: cargo clippy -- -D warnings
run: cargo clippy -p Readest --no-deps -- -D warnings
build_web_app:
runs-on: ubuntu-latest
Generated
+813 -811
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,3 +1,18 @@
[workspace]
members = ["apps/readest-app/src-tauri"]
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
]
resolver = "2"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
license = "AGPL-3.0"
repository = "https://github.com/readest/readest"
categories = []
edition = "2021"
rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
+34 -1
View File
@@ -26,7 +26,7 @@
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#contributors">Contributors</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#license">License</a>
</p>
@@ -199,6 +199,39 @@ nix develop ./ops#android # enter a dev shell for the android app
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Troubleshooting
### 1. Readest Wont Launch on Windows (Missing Edge WebView2 Runtime)
**Symptom**
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
- This can affect both the standard installer and the portable version.
**Cause**
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
**How to Fix**
1. Check if WebView2 is installed
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
2. Install or Update WebView2
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
- If you prefer an offline installer, download the offline package and run it as an Administrator.
3. Re-run Readest
- After installing/updating WebView2, launch readest.exe again.
- If you still encounter problems, reboot your PC and try again.
**Additional Tips**
- If reinstalling once doesnt work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
- Verify your Windows installation has the latest updates from Microsoft.
**Still Stuck?**
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps youve taken.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
+11
View File
@@ -4,14 +4,25 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
# r2, s3
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
S3_ENDPOINT=PLACE_HOLDER
S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.26",
"version": "0.9.31",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -42,19 +42,19 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/api": "2.4.1",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.3",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-haptics": "^2.2.4",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
@@ -79,7 +79,7 @@
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.3.1",
"@tauri-apps/cli": "2.4.1",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -152,7 +152,6 @@
"Text Indent": "مسافة بادئة للنص",
"Paragraph Margin": "هامش الفقرة",
"Override Book Layout": "تجاوز تخطيط الكتاب",
"Add to Group": "إضافة إلى مجموعة",
"Untitled Group": "مجموعة بدون عنوان",
"Group Books": "تجميع الكتب",
"Remove From Group": "إزالة من المجموعة",
@@ -260,5 +259,19 @@
"Copied to clipboard": "تم النسخ إلى الحافظة",
"Export Annotations": "تصدير التعليقات",
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
"No chapters detected.": "لم يتم التعرف على فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع",
"Always on Top": "دائمًا في المقدمة",
"No Timeout": "لا مهلة",
"{{value}} minute": "{{value}} دقيقة",
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث"
}
@@ -152,7 +152,6 @@
"Text Indent": "Texteinzug",
"Paragraph Margin": "Absatzrand",
"Override Book Layout": "Buch-Layout überschreiben",
"Add to Group": "Zur Gruppe hinzufügen",
"Untitled Group": "Unbenannte Gruppe",
"Group Books": "Bücher gruppieren",
"Remove From Group": "Aus Gruppe entfernen",
@@ -260,5 +259,19 @@
"Copied to clipboard": "In die Zwischenablage kopiert",
"Export Annotations": "Anmerkungen exportieren",
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen",
"No chapters detected.": "Keine Kapitel erkannt.",
"Failed to parse the EPUB file.": "Fehler beim Parsen der EPUB-Datei.",
"This book format is not supported.": "Dieses Buchformat wird nicht unterstützt.",
"Unable to fetch the translation. Please log in first and try again.": "Übersetzung konnte nicht abgerufen werden. Bitte zuerst anmelden und erneut versuchen.",
"Group": "Gruppieren",
"Always on Top": "Immer im Vordergrund",
"No Timeout": "Kein Timeout",
"{{value}} minute": "{{value}} Minute",
"{{value}} minutes": "{{value}} Minuten",
"{{value}} hour": "{{value}} Stunde",
"{{value}} hours": "{{value}} Stunden",
"CJK Font": "CJK-Schriftart",
"Clear Search": "Suche löschen"
}
@@ -152,7 +152,6 @@
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
"Export Annotations": "Εξαγωγή σχολιασμών",
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
"Unable to fetch the translation. Please log in first and try again.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά.",
"Group": "Ομάδα",
"Always on Top": "Πάντα στην κορυφή",
"No Timeout": "Χωρίς χρονικό όριο",
"{{value}} minute": "{{value}} λεπτό",
"{{value}} minutes": "{{value}} λεπτά",
"{{value}} hour": "{{value}} ώρα",
"{{value}} hours": "{{value}} ώρες",
"CJK Font": "Γραμματοσειρά CJK",
"Clear Search": "Καθαρισμός αναζήτησης"
}
@@ -152,7 +152,6 @@
"Text Indent": "Sangría de texto",
"Paragraph Margin": "Margen de párrafo",
"Override Book Layout": "Sobrescribir diseño del libro",
"Add to Group": "Añadir a grupo",
"Untitled Group": "Grupo sin título",
"Group Books": "Agrupar libros",
"Remove From Group": "Eliminar del grupo",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Copiado al portapapeles",
"Export Annotations": "Exportar anotaciones",
"Auto Import on File Open": "Importación automática al abrir archivo",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear",
"No chapters detected.": "No se detectaron capítulos.",
"Failed to parse the EPUB file.": "Error al analizar el archivo EPUB.",
"This book format is not supported.": "Este formato de libro no es compatible.",
"Unable to fetch the translation. Please log in first and try again.": "No se puede obtener la traducción. Por favor, inicia sesión primero e inténtalo de nuevo.",
"Group": "Grupo",
"Always on Top": "Siempre en la parte superior",
"No Timeout": "Sin tiempo de espera",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fuente CJK",
"Clear Search": "Limpiar búsqueda"
}
@@ -152,7 +152,6 @@
"Text Indent": "Retrait du texte",
"Paragraph Margin": "Marge de paragraphe",
"Override Book Layout": "Remplacer la mise en page du livre",
"Add to Group": "Ajouter au groupe",
"Untitled Group": "Groupe sans titre",
"Group Books": "Regrouper les livres",
"Remove From Group": "Retirer du groupe",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Copié dans le presse-papiers",
"Export Annotations": "Exporter les annotations",
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner",
"No chapters detected.": "Aucun chapitre détecté.",
"Failed to parse the EPUB file.": "Échec de l'analyse du fichier EPUB.",
"This book format is not supported.": "Ce format de livre n'est pas pris en charge.",
"Unable to fetch the translation. Please log in first and try again.": "Impossible de récupérer la traduction. Veuillez d'abord vous connecter et réessayer.",
"Group": "Groupe",
"Always on Top": "Toujours au-dessus",
"No Timeout": "Aucun délai d'attente",
"{{value}} minute": "{{value}} minute",
"{{value}} minutes": "{{value}} minutes",
"{{value}} hour": "{{value}} heure",
"{{value}} hours": "{{value}} heures",
"CJK Font": "Police CJK",
"Clear Search": "Effacer la recherche"
}
@@ -152,7 +152,6 @@
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
@@ -260,5 +259,19 @@
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
"Export Annotations": "टिप्पणियाँ निर्यात करें",
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
"No chapters detected.": "कोई अध्याय नहीं मिला।",
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
"Unable to fetch the translation. Please log in first and try again.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।",
"Group": "समूह",
"Always on Top": "सर्वोच्च पर हमेशा",
"No Timeout": "कोई समय समाप्त नहीं",
"{{value}} minute": "{{value}} मिनट",
"{{value}} minutes": "{{value}} मिनट",
"{{value}} hour": "{{value}} घंटा",
"{{value}} hours": "{{value}} घंटे",
"CJK Font": "CJK फ़ॉन्ट",
"Clear Search": "खोज साफ करें"
}
@@ -152,7 +152,6 @@
"Text Indent": "Indentasi Teks",
"Paragraph Margin": "Margin Paragraf",
"Override Book Layout": "Ganti Tata Letak Buku",
"Add to Group": "Tambahkan ke Grup",
"Untitled Group": "Grup Tanpa Judul",
"Group Books": "Grupkan Buku",
"Remove From Group": "Hapus dari Grup",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Disalin ke papan klip",
"Export Annotations": "Ekspor Anotasi",
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik",
"No chapters detected.": "Tidak ada bab yang terdeteksi.",
"Failed to parse the EPUB file.": "Gagal mengurai file EPUB.",
"This book format is not supported.": "Format buku ini tidak didukung.",
"Unable to fetch the translation. Please log in first and try again.": "Tidak dapat mengambil terjemahan. Silakan masuk terlebih dahulu dan coba lagi.",
"Group": "Grup",
"Always on Top": "Selalu di Atas",
"No Timeout": "Tidak Ada Waktu Habis",
"{{value}} minute": "{{value}} menit",
"{{value}} minutes": "{{value}} menit",
"{{value}} hour": "{{value}} jam",
"{{value}} hours": "{{value}} jam",
"CJK Font": "Font CJK",
"Clear Search": "Hapus Pencarian"
}
@@ -152,7 +152,6 @@
"Text Indent": "Rientro testo",
"Paragraph Margin": "Margine paragrafo",
"Override Book Layout": "Sovrascrivi layout libro",
"Add to Group": "Aggiungi al gruppo",
"Untitled Group": "Gruppo senza titolo",
"Group Books": "Raggruppa libri",
"Remove From Group": "Rimuovi dal",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Copiato negli appunti",
"Export Annotations": "Esporta annotazioni",
"Auto Import on File Open": "Importazione automatica all'apertura del file",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina",
"No chapters detected.": "Nessun capitolo rilevato.",
"Failed to parse the EPUB file.": "Impossibile analizzare il file EPUB.",
"This book format is not supported.": "Questo formato di libro non è supportato.",
"Unable to fetch the translation. Please log in first and try again.": "Impossibile recuperare la traduzione. Accedi prima e riprova.",
"Group": "Gruppo",
"Always on Top": "Sempre in primo piano",
"No Timeout": "Nessun timeout",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minuti",
"{{value}} hour": "{{value}} ora",
"{{value}} hours": "{{value}} ore",
"CJK Font": "Font CJK",
"Clear Search": "Cancella ricerca"
}
@@ -152,7 +152,6 @@
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
@@ -260,5 +259,19 @@
"Copied to clipboard": "クリップボードにコピー",
"Export Annotations": "注釈をエクスポート",
"Auto Import on File Open": "ファイルを開くと自動インポート",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
"No chapters detected.": "章が検出されません。",
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
"This book format is not supported.": "この書籍形式はサポートされていません。",
"Unable to fetch the translation. Please log in first and try again.": "翻訳を取得できません。まずログインして再試行してください。",
"Group": "グループ",
"Always on Top": "常に最前面",
"No Timeout": "タイムアウトなし",
"{{value}} minute": "{{value}}分",
"{{value}} minutes": "{{value}}分",
"{{value}} hour": "{{value}}時間",
"{{value}} hours": "{{value}}時間",
"CJK Font": "CJKフォント",
"Clear Search": "検索をクリア"
}
@@ -152,7 +152,6 @@
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
@@ -260,5 +259,19 @@
"Copied to clipboard": "클립보드에 복사",
"Export Annotations": "주석 내보내기",
"Auto Import on File Open": "파일 열 때 자동 가져오기",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
"No chapters detected.": "장 발견되지 않음.",
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
"Unable to fetch the translation. Please log in first and try again.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요.",
"Group": "그룹",
"Always on Top": "항상 위에",
"No Timeout": "타임아웃 없음",
"{{value}} minute": "{{value}} 분",
"{{value}} minutes": "{{value}} 분",
"{{value}} hour": "{{value}} 시간",
"{{value}} hours": "{{value}} 시간",
"CJK Font": "CJK 글꼴",
"Clear Search": "검색 지우기"
}
@@ -152,7 +152,6 @@
"Text Indent": "Wcięcie tekstu",
"Paragraph Margin": "Margines akapitu",
"Override Book Layout": "Nadpisz układ książki",
"Add to Group": "Dodaj do grupy",
"Untitled Group": "Grupa bez tytułu",
"Group Books": "Grupuj książki",
"Remove From Group": "Usuń z grupy",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Skopiowano do schowka",
"Export Annotations": "Eksportuj adnotacje",
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie",
"No chapters detected.": "Nie wykryto rozdziałów.",
"Failed to parse the EPUB file.": "Nie udało się przeanalizować pliku EPUB.",
"This book format is not supported.": "Ten format książki nie jest obsługiwany.",
"Unable to fetch the translation. Please log in first and try again.": "Nie można pobrać tłumaczenia. Proszę najpierw się zalogować i spróbować ponownie.",
"Group": "Grupa",
"Always on Top": "Zawsze na wierzchu",
"No Timeout": "Brak limitu czasu",
"{{value}} minute": "{{value}} minuta",
"{{value}} minutes": "{{value}} minut",
"{{value}} hour": "{{value}} godzina",
"{{value}} hours": "{{value}} godziny",
"CJK Font": "Czcionka CJK",
"Clear Search": "Wyczyść wyszukiwanie"
}
@@ -152,7 +152,6 @@
"Text Indent": "Recuo de Texto",
"Paragraph Margin": "Margem de Parágrafo",
"Override Book Layout": "Sobrescrever Layout do Livro",
"Add to Group": "Adicionar ao Grupo",
"Untitled Group": "Grupo Sem Título",
"Group Books": "Agrupar Livros",
"Remove From Group": "Remover do Grupo",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Copiado para a área de transferência",
"Export Annotations": "Exportar Anotações",
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar",
"No chapters detected.": "Nenhum capítulo detectado.",
"Failed to parse the EPUB file.": "Falha ao analisar o arquivo EPUB.",
"This book format is not supported.": "Este formato de livro não é suportado.",
"Unable to fetch the translation. Please log in first and try again.": "Não foi possível buscar a tradução. Faça login primeiro e tente novamente.",
"Group": "Grupo",
"Always on Top": "Sempre no Topo",
"No Timeout": "Sem Tempo Limite",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fonte CJK",
"Clear Search": "Limpar Pesquisa"
}
@@ -152,7 +152,6 @@
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Скопировано в буфер обмена",
"Export Annotations": "Экспортировать аннотации",
"Auto Import on File Open": "Автоматический импорт при открытии файла",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
"No chapters detected.": "Не обнаружено глав.",
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
"This book format is not supported.": "Этот формат книги не поддерживается.",
"Unable to fetch the translation. Please log in first and try again.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова.",
"Group": "Группа",
"Always on Top": "Всегда сверху",
"No Timeout": "Нет таймаута",
"{{value}} minute": "{{value}} минута",
"{{value}} minutes": "{{value}} минуты",
"{{value}} hour": "{{value}} час",
"{{value}} hours": "{{value}} часа",
"CJK Font": "CJK Шрифт",
"Clear Search": "Очистить поиск"
}
@@ -152,7 +152,6 @@
"Text Indent": "Metin Girintisi",
"Paragraph Margin": "Paragraf Kenarı",
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
"Add to Group": "Gruba Ekle",
"Untitled Group": "Başlıksız Grup",
"Group Books": "Kitapları Grupla",
"Remove From Group": "Grubu Kaldır",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Panoya kopyalandı",
"Export Annotations": "Notları Dışa Aktar",
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir",
"No chapters detected.": "Bölüm bulunamadı.",
"Failed to parse the EPUB file.": "EPUB dosyası ayrıştırılamadı.",
"This book format is not supported.": "Bu kitap formatı desteklenmiyor.",
"Unable to fetch the translation. Please log in first and try again.": "Çeviri alınamıyor. Lütfen önce giriş yapın ve tekrar deneyin.",
"Group": "Grup",
"Always on Top": "Her Zaman Üstte",
"No Timeout": "Zaman Aşımı Yok",
"{{value}} minute": "{{value}} dakika",
"{{value}} minutes": "{{value}} dakika",
"{{value}} hour": "{{value}} saat",
"{{value}} hours": "{{value}} saat",
"CJK Font": "CJK Yazı Tipi",
"Clear Search": "Aramayı Temizle"
}
@@ -152,7 +152,6 @@
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Скопійовано в буфер обміну",
"Export Annotations": "Експортувати анотації",
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
"No chapters detected.": "Не виявлено жодного розділу.",
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
"This book format is not supported.": "Цей формат книги не підтримується.",
"Unable to fetch the translation. Please log in first and try again.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз.",
"Group": "Група",
"Always on Top": "Завжди зверху",
"No Timeout": "Без тайм-ауту",
"{{value}} minute": "{{value}} хвилина",
"{{value}} minutes": "{{value}} хвилин",
"{{value}} hour": "{{value}} година",
"{{value}} hours": "{{value}} години",
"CJK Font": "CJK шрифт",
"Clear Search": "Очистити пошук"
}
@@ -152,7 +152,6 @@
"Text Indent": "Thụt lề",
"Paragraph Margin": "Lề đoạn",
"Override Book Layout": "Ghi đè bố cục sách",
"Add to Group": "Thêm vào nhóm",
"Untitled Group": "Nhóm không có tiêu đề",
"Group Books": "Nhóm sách",
"Remove From Group": "Xóa khỏi nhóm",
@@ -260,5 +259,19 @@
"Copied to clipboard": "Đã sao chép vào bảng ghi",
"Export Annotations": "Xuất chú thích",
"Auto Import on File Open": "Tự động nhập khi mở tệp",
"LXGW WenKai GB Screen": "LXGW WenKai"
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang",
"No chapters detected.": "Không phát hiện thấy chương nào.",
"Failed to parse the EPUB file.": "Không thể phân tích tệp EPUB.",
"This book format is not supported.": "Định dạng sách này không được hỗ trợ.",
"Unable to fetch the translation. Please log in first and try again.": "Không thể tải dịch vụ. Vui lòng đăng nhập trước và thử lại.",
"Group": "Nhóm",
"Always on Top": "Luôn ở trên cùng",
"No Timeout": "Không có thời gian chờ",
"{{value}} minute": "{{value}} phút",
"{{value}} minutes": "{{value}} phút",
"{{value}} hour": "{{value}} giờ",
"{{value}} hours": "{{value}} giờ",
"CJK Font": "Phông chữ CJK",
"Clear Search": "Xóa tìm kiếm"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
@@ -231,7 +230,7 @@
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"Fullscreen": "全屏显示",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
@@ -260,5 +259,19 @@
"Copied to clipboard": "已复制到剪贴板",
"Export Annotations": "导出笔记",
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷"
"LXGW WenKai GB Screen": "霞鹜文楷",
"Swap Click-to-Flip Area": "交换点击翻页区域",
"No chapters detected.": "未检测到章节。",
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
"This book format is not supported.": "不支持此书籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "无法获取翻译。请登录后重试。",
"Group": "分组",
"Always on Top": "窗口置顶",
"No Timeout": "关闭定时",
"{{value}} minute": "{{value}}分钟",
"{{value}} minutes": "{{value}}分钟",
"{{value}} hour": "{{value}}小时",
"{{value}} hours": "{{value}}小时",
"CJK Font": "中文字体",
"Clear Search": "清除搜索"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
@@ -260,5 +259,19 @@
"Copied to clipboard": "已複製到剪貼板",
"Export Annotations": "匯出筆記",
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷"
"LXGW WenKai GB Screen": "霞鶩文楷",
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
"No chapters detected.": "未檢測到章節。",
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
"This book format is not supported.": "不支援此書籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "無法獲取翻譯。請先登入然後重試。",
"Group": "分組",
"Always on Top": "視窗置頂",
"No Timeout": "關閉定時",
"{{value}} minute": "{{value}}分鐘",
"{{value}} minutes": "{{value}}分鐘",
"{{value}} hour": "{{value}}小時",
"{{value}} hours": "{{value}}小時",
"CJK Font": "中文字體",
"Clear Search": "清除搜索"
}
+46
View File
@@ -1,5 +1,51 @@
{
"releases": {
"0.9.31": {
"date": "2025-04-08",
"notes": [
"iOS: Text-to-Speech (TTS) now works in the background",
"Android: Import files from native file manager",
"Desktop: Add option to keep window on top",
"Vertical layout for highlighting tools",
"Added options to auto-stop TTS after timeout",
"Added book search functionality in bookshelf",
"Improved font support for CJK users"
]
},
"0.9.30": {
"date": "2025-04-05",
"notes": [
"Added fixed storage quata when running the app in self-hosted mode",
"Link colors will only change when you override fonts",
"Added cache for native files for better performance"
]
},
"0.9.29": {
"date": "2025-04-02",
"notes": [
"Improved Login on Android without needing to rely on external Chrome browser",
"Local large book files now load more efficiently on Android",
"Balanced the load of online service between different types of users to keep things running smoothly for everyone"
]
},
"0.9.28": {
"date": "2025-03-30",
"notes": [
"Improved vertical reading mode with better support for Chinese punctuations",
"Fixed TXT file importing issue on desktop apps — TXT books now load reliably and display as expected",
"Improved annotation experience — highlighting tools are now properly dismissed when adding notes to avoid UI conflicts"
]
},
"0.9.27": {
"date": "2025-03-29",
"notes": [
"Added option to swap click-to-flip area and reset password",
"Added global shortcuts for full-text search",
"Added support for both R2 and S3 storage in cloud sync",
"Improved TTS with fewer unintended pauses, faster startup, and support for preferred voices",
"Various fixes for rendering styles and UI layout"
]
},
"0.9.26": {
"date": "2025-03-27",
"notes": [
+18 -21
View File
@@ -1,6 +1,6 @@
[package]
name = "Readest"
version = "0.2.1"
version = "0.2.2"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
@@ -15,7 +15,7 @@ name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.0.3", features = [] }
tauri-build = { version = "2.1.1", features = [] }
[dependencies]
serde_json = "1.0"
@@ -31,24 +31,21 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.0.3"
tauri-plugin-log = "2.0.2"
tauri-plugin-fs = "2.0.3"
tauri-plugin-dialog = "2.0.3"
tauri-plugin-os = "2.0.1"
tauri-plugin-http = "2.0.3"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri = { version = "2.4.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.1"
tauri-plugin-log = "2.3.1"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.3"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.3"
tauri-plugin-safari-auth = { path = "./plugins/tauri-plugin-safari-auth" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
tauri-plugin-haptics = "2.2.4"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"
@@ -57,7 +54,7 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = "2.7.0"
tauri-plugin-window-state = "2.2.2"
font-enumeration = "0.9.0"
@@ -63,6 +63,8 @@
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-always-on-top",
"log:default",
"shell:default",
"process:default",
"process:allow-exit",
@@ -75,7 +77,7 @@
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"safari-auth:default",
"native-bridge:default",
"deep-link:default"
]
}
@@ -19,6 +19,12 @@
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter android:label="oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -2,10 +2,14 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
import app.tauri.plugin.JSObject
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,6 +19,20 @@ class MainActivity : TauriActivity() {
hideSystemUI()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
NativeBridgePlugin.pendingInvoke?.resolve(result)
NativeBridgePlugin.pendingInvoke = null
}
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.let { controller ->
@@ -0,0 +1,19 @@
[package]
name = "tauri-plugin-native-bridge"
version = "0.1.0"
authors = [ "You" ]
description = "a bridge between tauri app and native OS functionality"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-bridge"
[dependencies]
tauri = { version = "2.3.1" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.0.4", features = ["build"] }
schemars = "0.8"
@@ -0,0 +1 @@
# Tauri Plugin native-bridge
@@ -4,7 +4,7 @@ plugins {
}
android {
namespace = "com.plugin.safari_auth"
namespace = "com.readest.native_bridge"
compileSdk = 34
defaultConfig {
@@ -33,9 +33,9 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.browser:browser:1.8.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -1,4 +1,4 @@
package com.plugin.safari_auth
package com.readest.native_bridge
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.plugin.safari_auth", appContext.packageName)
assertEquals("com.readest.native_bridge", appContext.packageName)
}
}
@@ -0,0 +1,6 @@
package com.readest.native_bridge
import android.util.Log
class NativeBridge {
}
@@ -0,0 +1,77 @@
package com.readest.native_bridge
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import java.io.*
@InvokeArg
class AuthRequestArgs {
var authUrl: String? = null
}
@InvokeArg
class CopyURIRequestArgs {
var uri: String? = null
var dst: String? = null
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
private var redirectHost = "auth-callback"
companion object {
var pendingInvoke: Invoke? = null
}
@Command
fun auth_with_custom_tab(invoke: Invoke) {
val args = invoke.parseArgs(AuthRequestArgs::class.java)
val uri = Uri.parse(args.authUrl)
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
Log.d("NativeBridgePlugin", "Launching OAuth URL: ${args.authUrl}")
customTabsIntent.launchUrl(activity, uri)
pendingInvoke = invoke
}
@Command
fun copy_uri_to_path(invoke: Invoke) {
val args = invoke.parseArgs(CopyURIRequestArgs::class.java)
val ret = JSObject()
try {
val uri = Uri.parse(args.uri ?: "")
val dst = File(args.dst ?: "")
val inputStream = activity.contentResolver.openInputStream(uri)
if (inputStream != null) {
dst.outputStream().use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "Failed to open input stream from URI")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
}
@@ -0,0 +1,13 @@
const COMMANDS: &[&str] = &[
"auth_with_safari",
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -4,7 +4,7 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
@@ -12,9 +12,9 @@ let package = Package(
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
type: .static,
targets: ["tauri-plugin-safari-auth"]),
targets: ["tauri-plugin-native-bridge"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
@@ -23,7 +23,7 @@ let package = Package(
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-safari-auth",
name: "tauri-plugin-native-bridge",
dependencies: [
.byName(name: "Tauri")
],
@@ -1,3 +1,3 @@
# Tauri Plugin safari-auth
# Tauri Plugin native-bridge
A description of this package.
@@ -1,22 +1,44 @@
import AuthenticationServices
import AVFoundation
import MediaPlayer
import SwiftRs
import Tauri
import UIKit
import WebKit
class RequestArgs: Decodable {
class SafariAuthRequestArgs: Decodable {
let authUrl: String
}
struct Response: Encodable {
let redirectUrl: String
class UseBackgroundAudioRequestArgs: Decodable {
let enabled: Bool
}
class SafariAuthPlugin: Plugin {
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@objc public func use_background_audio(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
let enabled = args.enabled
let session = AVAudioSession.sharedInstance()
if enabled {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
print("AVAudioSession activated")
} else {
try session.setActive(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("AVAudioSession deactivated")
}
invoke.resolve()
} catch {
print("Failed to set up audio session:", error)
}
}
@objc public func auth_with_safari(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(RequestArgs.self)
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
let authUrl = URL(string: args.authUrl)!
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
@@ -46,14 +68,14 @@ class SafariAuthPlugin: Plugin {
}
}
@_cdecl("init_plugin_safari_auth")
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return SafariAuthPlugin()
return NativeBridgePlugin()
}
@available(iOS 13.0, *)
extension SafariAuthPlugin: ASWebAuthenticationPresentationContextProviding {
extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
}
@@ -0,0 +1,8 @@
import XCTest
@testable import NativeBridgePlugin
final class NativeBridgePluginTests: XCTestCase {
func testNativeBridge() throws {
let plugin = NativeBridgePlugin()
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-auth-with-custom-tab"
description = "Enables the auth_with_custom_tab command without any pre-configured scope."
commands.allow = ["auth_with_custom_tab"]
[[permission]]
identifier = "deny-auth-with-custom-tab"
description = "Denies the auth_with_custom_tab command without any pre-configured scope."
commands.deny = ["auth_with_custom_tab"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-copy-uri-to-path"
description = "Enables the copy_uri_to_path command without any pre-configured scope."
commands.allow = ["copy_uri_to_path"]
[[permission]]
identifier = "deny-copy-uri-to-path"
description = "Denies the copy_uri_to_path command without any pre-configured scope."
commands.deny = ["copy_uri_to_path"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-use-background-audio"
description = "Enables the use_background_audio command without any pre-configured scope."
commands.allow = ["use_background_audio"]
[[permission]]
identifier = "deny-use-background-audio"
description = "Denies the use_background_audio command without any pre-configured scope."
commands.deny = ["use_background_audio"]
@@ -0,0 +1,124 @@
## Default Permission
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-auth-with-safari`
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`native-bridge:allow-auth-with-custom-tab`
</td>
<td>
Enables the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-auth-with-custom-tab`
</td>
<td>
Denies the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-auth-with-safari`
</td>
<td>
Enables the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-auth-with-safari`
</td>
<td>
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-copy-uri-to-path`
</td>
<td>
Enables the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-copy-uri-to-path`
</td>
<td>
Denies the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
<td>
Enables the use_background_audio command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-use-background-audio`
</td>
<td>
Denies the use_background_audio command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"]
@@ -294,20 +294,59 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-custom-tab",
"markdownDescription": "Enables the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Denies the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-custom-tab",
"markdownDescription": "Denies the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Enables the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-safari"
"const": "allow-auth-with-safari",
"markdownDescription": "Enables the auth_with_safari command without any pre-configured scope."
},
{
"description": "Denies the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-safari"
"const": "deny-auth-with-safari",
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin",
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "default"
"const": "allow-copy-uri-to-path",
"markdownDescription": "Enables the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Denies the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
"const": "allow-use-background-audio",
"markdownDescription": "Enables the use_background_audio command without any pre-configured scope."
},
{
"description": "Denies the use_background_audio command without any pre-configured scope.",
"type": "string",
"const": "deny-use-background-audio",
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`"
}
]
}
@@ -0,0 +1,37 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::NativeBridgeExt;
use crate::Result;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_safari(payload)
}
#[command]
pub(crate) async fn auth_with_custom_tab<R: Runtime>(
app: AppHandle<R>,
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_custom_tab(payload)
}
#[command]
pub(crate) async fn copy_uri_to_path<R: Runtime>(
app: AppHandle<R>,
payload: CopyURIRequest,
) -> Result<CopyURIResponse> {
app.native_bridge().copy_uri_to_path(payload)
}
#[command]
pub(crate) async fn use_background_audio<R: Runtime>(
app: AppHandle<R>,
payload: UseBackgroundAudioRequest,
) -> Result<()> {
app.native_bridge().use_background_audio(payload)
}
@@ -0,0 +1,32 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
Ok(NativeBridge(app.clone()))
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn auth_with_custom_tab(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -6,6 +6,8 @@ pub type Result<T> = std::result::Result<T, Error>;
pub enum Error {
#[error("Unsupported platform for this plugin")]
UnsupportedPlatformError,
#[error("Native bridge error: {0}")]
NativeBridgeError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
@@ -0,0 +1,54 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
mod platform;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::NativeBridge;
#[cfg(mobile)]
use mobile::NativeBridge;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the native-bridge APIs.
pub trait NativeBridgeExt<R: Runtime> {
fn native_bridge(&self) -> &NativeBridge<R>;
}
impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
fn native_bridge(&self) -> &NativeBridge<R> {
self.state::<NativeBridge<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
.invoke_handler(tauri::generate_handler![
commands::auth_with_safari,
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_bridge = mobile::init(app, api)?;
#[cfg(desktop)]
let native_bridge = desktop::init(app, api)?;
app.manage(native_bridge);
Ok(())
})
.build()
}
@@ -0,0 +1,57 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_native_bridge);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
Ok(NativeBridge(handle))
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_custom_tab(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_custom_tab", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn copy_uri_to_path(&self, payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
self.0
.run_mobile_plugin("copy_uri_to_path", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("use_background_audio", payload)
.map_err(Into::into)
}
}
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResponse {
pub redirect_url: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIRequest {
pub uri: String,
pub dst: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UseBackgroundAudioRequest {
pub enabled: bool,
}
@@ -0,0 +1,2 @@
#[cfg(target_os = "macos")]
pub mod macos;
@@ -1,17 +0,0 @@
[package]
name = "tauri-plugin-safari-auth"
version = "0.1.0"
authors = [ "chrox" ]
description = "OAuth with ASWebAuthenticationSession on iOS"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-safari-auth"
[dependencies]
tauri = { version = "2.2.4" }
serde = "1.0"
thiserror = "2"
[build-dependencies]
tauri-plugin = { version = "2.0.3", features = ["build"] }
@@ -1 +0,0 @@
# Tauri Plugin safari-auth
@@ -1,10 +0,0 @@
package com.plugin.safari_auth
import android.util.Log
class Example {
fun pong(value: String): String {
Log.i("Pong", value)
return value
}
}
@@ -1,28 +0,0 @@
package com.plugin.safari_auth
import android.app.Activity
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
@InvokeArg
class PingArgs {
var value: String? = null
}
@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = Example()
@Command
fun ping(invoke: Invoke) {
val args = invoke.parseArgs(PingArgs::class.java)
val ret = JSObject()
ret.put("value", implementation.pong(args.value ?: "default value :("))
invoke.resolve(ret)
}
}
@@ -1,8 +0,0 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -1,8 +0,0 @@
import XCTest
@testable import SafariAuthPlugin
final class SafariAuthPluginTests: XCTestCase {
func testExample() throws {
let plugin = SafariAuthPlugin()
}
}
@@ -1,41 +0,0 @@
## Default Permission
Default permissions for the plugin
- `allow-auth-with-safari`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`safari-auth:allow-auth-with-safari`
</td>
<td>
Enables the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`safari-auth:deny-auth-with-safari`
</td>
<td>
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
</table>
@@ -1,3 +0,0 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari"]
@@ -1,13 +0,0 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::Result;
use crate::SafariAuthExt;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: SafariAuthRequest,
) -> Result<SafariAuthResponse> {
app.safari_auth().auth_with_safari(payload)
}
@@ -1,23 +0,0 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<SafariAuth<R>> {
Ok(SafariAuth(app.clone()))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(AppHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
_payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -1,48 +0,0 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::SafariAuth;
#[cfg(mobile)]
use mobile::SafariAuth;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the safari-auth APIs.
pub trait SafariAuthExt<R: Runtime> {
fn safari_auth(&self) -> &SafariAuth<R>;
}
impl<R: Runtime, T: Manager<R>> crate::SafariAuthExt<R> for T {
fn safari_auth(&self) -> &SafariAuth<R> {
self.state::<SafariAuth<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("safari-auth")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.setup(|app, api| {
#[cfg(mobile)]
let safari_auth = mobile::init(app, api)?;
#[cfg(desktop)]
let safari_auth = desktop::init(app, api)?;
app.manage(safari_auth);
Ok(())
})
.build()
}
@@ -1,37 +0,0 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_safari_auth);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<SafariAuth<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
Ok(SafariAuth(handle))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
@@ -1,13 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthResponse {
pub redirect_url: String,
}
+36 -31
View File
@@ -46,6 +46,31 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may also be passed as `file://path/to/file`
for (_, maybe_file) in argv.iter().enumerate().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
} else {
files.push(PathBuf::from(maybe_file))
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
files
}
#[cfg(desktop)]
fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
let files = files
@@ -119,6 +144,7 @@ pub fn run() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
@@ -127,6 +153,10 @@ pub fn run() {
.get_webview_window("main")
.expect("no main window")
.set_focus();
let files = get_files_from_argv(argv.clone());
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
@@ -145,9 +175,6 @@ pub fn run() {
#[cfg(target_os = "ios")]
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
#[cfg(target_os = "ios")]
let builder = builder.plugin(tauri_plugin_safari_auth::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_haptics::init());
@@ -155,26 +182,7 @@ pub fn run() {
.setup(|#[allow(unused_variables)] app| {
#[cfg(desktop)]
{
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may also be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(&maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
} else {
files.push(PathBuf::from(maybe_file))
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
let files = get_files_from_argv(std::env::args().collect());
if !files.is_empty() {
let app_handle = app.handle().clone();
allow_file_in_scopes(&app_handle, files.clone());
@@ -209,14 +217,11 @@ pub fn run() {
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
@@ -16,9 +16,27 @@ export default function AuthCallback() {
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
const error = params.get('error');
const errorDescription = params.get('error_description');
const errorCode = params.get('error_code');
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
handleAuthCallback({
accessToken,
refreshToken,
type,
next,
error,
errorCode,
errorDescription,
login,
navigate: router.push,
});
return null;
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<span className='loading loading-infinity loading-xl w-20'></span>
</div>
);
}
+31 -21
View File
@@ -16,13 +16,15 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -39,8 +41,8 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = 'https://web.readest.com/auth/callback';
const DEEPLINK_CALLBACK = 'readest://auth/callback';
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
const DEEPLINK_CALLBACK = 'readest://auth-callback';
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
return (
@@ -63,6 +65,7 @@ export default function AuthPage() {
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useThemeStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
@@ -72,16 +75,20 @@ export default function AuthPage() {
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (appService?.isIOSApp) {
if (appService?.isMobile) {
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
} else if (appService?.isAndroidApp) {
return WEB_AUTH_CALLBACK;
}
return DEEPLINK_CALLBACK;
}
return `http://localhost:${port}`; // only for development env on Desktop
};
const getWebRedirectTo = () => {
return process.env.NODE_ENV === 'production'
? WEB_AUTH_CALLBACK
: `${window.location.origin}/auth/callback`;
};
const tauriSignInApple = async () => {
if (!supabase) {
throw new Error('No backend connected');
@@ -126,6 +133,11 @@ export default function AuthPage() {
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else if (appService?.isAndroidApp) {
const res = await authWithCustomTab({ authUrl: data.url });
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else {
await openUrl(data.url);
}
@@ -139,9 +151,10 @@ export default function AuthPage() {
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push });
}
}
};
@@ -238,13 +251,6 @@ export default function AuthPage() {
link_text: _('Forgot your password?'),
confirmation_text: _('Check your email for the password reset link'),
},
update_password: {
password_label: _('New Password'),
password_input_placeholder: _('Your new password'),
button_label: _('Update password'),
loading_button_label: _('Updating password ...'),
confirmation_text: _('Your password has been updated'),
},
verify_otp: {
email_input_label: _('Email address'),
email_input_placeholder: _('Your email address'),
@@ -301,14 +307,15 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
>
<div
ref={headerRef}
className={clsx(
'flex w-full items-center justify-between py-2 pe-6 ps-4',
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
@@ -319,14 +326,17 @@ export default function AuthPage() {
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={appService?.hasWindowBar}
showMaximize={appService?.hasWindowBar}
showClose={appService?.hasWindowBar}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onClose={handleGoBack}
/>
)}
</div>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<div
className={clsx('z-20 pb-8', appService?.hasTrafficLight ? 'mt-24' : 'mt-12')}
style={{ maxWidth: '420px' }}
>
<ProviderLogin
provider='google'
handleSignIn={tauriSignIn}
@@ -371,7 +381,7 @@ export default function AuthPage() {
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={['google', 'apple', 'github']}
redirectTo='/auth/callback'
redirectTo={getWebRedirectTo()}
localization={getAuthLocalization()}
/>
</div>
@@ -0,0 +1,62 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { Auth } from '@supabase/auth-ui-react';
import { supabase } from '@/utils/supabase';
export default function ResetPasswordPage() {
const _ = useTranslation();
const router = useRouter();
const { login } = useAuth();
const { isDarkMode } = useThemeStore();
const getAuthLocalization = () => {
return {
variables: {
update_password: {
password_label: _('New Password'),
password_input_placeholder: _('Your new password'),
button_label: _('Update password'),
loading_button_label: _('Updating password ...'),
confirmation_text: _('Your password has been updated'),
},
},
};
};
useEffect(() => {
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
if (session?.access_token && session.user && event === 'USER_UPDATED') {
login(session.access_token, session.user);
const redirectTo = new URLSearchParams(window.location.search).get('redirect');
router.push(redirectTo ?? '/library');
}
});
return () => {
subscription?.subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='w-full max-w-md'>
<Auth
supabaseClient={supabase}
view='update_password'
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={false}
providers={[]}
localization={getAuthLocalization()}
/>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { invoke } from '@tauri-apps/api/core';
export interface AuthRequest {
authUrl: string;
}
export interface AuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: AuthRequest): Promise<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
return result;
}
export async function authWithCustomTab(request: AuthRequest): Promise<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_custom_tab', {
payload: request,
});
return result;
}
@@ -1,17 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface SafariAuthRequest {
authUrl: string;
}
export interface SafariAuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
const result = await invoke<SafariAuthResponse>('plugin:safari-auth|auth_with_safari', {
payload: request,
});
return result;
}
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
@@ -49,6 +50,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
@@ -86,6 +88,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
if (query) {
setQueryTerm(query);
} else {
setQueryTerm(null);
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
@@ -93,11 +101,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -137,6 +145,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
};
const filteredBookshelfItems = currentBookshelfItems.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
});
return (
<div className='bookshelf'>
@@ -146,7 +166,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
)}
>
{currentBookshelfItems.map((item, index) => (
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
item={item}
@@ -210,7 +230,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
<div>{_('Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
@@ -9,7 +9,7 @@ import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { getLocalBookFilename } from '@/utils/book';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -94,6 +94,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
@@ -133,7 +134,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
@@ -152,7 +155,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
revealItemInDir(folder);
},
});
@@ -1,17 +1,18 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
@@ -40,6 +41,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
@@ -49,6 +51,26 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
@@ -68,7 +90,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:pr-6',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -93,7 +115,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
@@ -103,6 +127,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
}}
className='text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
@@ -14,7 +14,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -32,6 +32,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
@@ -66,6 +67,15 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleAlwaysOnTop = () => {
settings.alwaysOnTop = !settings.alwaysOnTop;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysOnTop(settings.alwaysOnTop);
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -174,13 +184,20 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoCheckUpdates}
/>
)}
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
+37 -15
View File
@@ -8,7 +8,7 @@ import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/naviga
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { getFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
@@ -31,7 +31,7 @@ import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriQuitApp } from '@/utils/window';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
@@ -104,6 +104,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await checkForAppUpdates(_);
}
};
if (settings.alwaysOnTop) {
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
}
doCheckAppUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
@@ -161,9 +164,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
}
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
@@ -182,9 +187,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
return () => {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
@@ -296,6 +303,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: (string | File)[]) => {
setLoading(true);
const failedFiles = [];
const errorMap: [string, string][] = [
['No chapters detected.', _('No chapters detected.')],
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
for (const file of files) {
try {
const book = await appService?.importBook(file, libraryBooks);
@@ -306,12 +318,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
const baseFilename = getFilename(filename);
failedFiles.push(baseFilename);
const errorMessage =
error instanceof Error
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
: '';
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
console.error('Failed to import book:', filename, error);
@@ -322,7 +339,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
const selectFilesWeb = () => {
@@ -366,6 +386,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
@@ -441,7 +463,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
let files;
if (isTauriAppPlatform()) {
if (appService?.isMobile) {
if (appService?.isIOSApp) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
@@ -510,7 +532,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone mt-[52px] flex-grow overflow-y-auto px-4 sm:px-2',
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
@@ -28,7 +28,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
@@ -68,7 +68,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
className={clsx(
'relative h-full w-full overflow-hidden',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
<HeaderBar
@@ -80,7 +83,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.vertical && viewSettings.scrolled && (
<>
<div
@@ -138,6 +140,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
)}
<Annotator bookKey={bookKey} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
bookFormat={book.format}
@@ -9,7 +9,7 @@ import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -23,6 +23,7 @@ import {
handleTouchEnd,
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
import { transformContent } from '@/services/transformService';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -53,9 +54,31 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const docTransformHandler = (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (detail.type === 'text/css') return transformStylesheet(data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
};
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
console.log('doc index loaded:', detail.index);
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
@@ -154,6 +177,9 @@ const FoliateViewer: React.FC<{
viewRef.current = view;
setFoliateView(bookKey, view);
const { book } = view;
book.transformTarget?.addEventListener('data', docTransformHandler);
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
@@ -19,7 +19,6 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { getStyles } from '@/utils/style';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
@@ -59,14 +58,13 @@ const FooterBar: React.FC<FooterBarProps> = ({
const handleFontSizeChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleMarginChange = (value: number) => {
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings?.scrolled) {
@@ -76,7 +74,6 @@ const FooterBar: React.FC<FooterBarProps> = ({
const handleLineHeightChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleGoPrev = () => {
@@ -78,6 +78,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { view } = detail;
view.addEventListener('relocate', () => {
const { renderer } = view as FoliateView;
const viewSettings = getViewSettings(bookKey)!;
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
} else {
@@ -13,10 +13,12 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
import { useSidebarStore } from '@/store/sidebarStore';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible } = useSidebarStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
@@ -45,7 +47,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
appService?.hasRoundedWindow && 'rounded-window',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
@@ -14,6 +14,7 @@ import { ThemeMode } from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import MenuItem from '@/components/MenuItem';
interface ViewMenuProps {
@@ -28,11 +29,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, themeCode, setThemeMode } = useThemeStore();
const { themeMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
@@ -58,13 +59,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
};
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings!));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
@@ -77,11 +72,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
}, [isScrolledMode]);
useEffect(() => {
const view = getView(bookKey);
if (!view) return;
viewSettings!.zoomLevel = zoomLevel;
setViewSettings(bookKey, viewSettings!);
view.renderer.setStyles?.(getStyles(viewSettings!));
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
@@ -8,6 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationPopupProps {
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
position: Position;
trianglePosition: Position;
@@ -24,6 +26,7 @@ const OPTIONS_PADDING_PIX = 16;
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
dir,
isVertical,
buttons,
position,
trianglePosition,
@@ -39,17 +42,20 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
return (
<div dir={dir}>
<Popup
width={popupWidth}
height={popupHeight}
width={isVertical ? popupHeight : popupWidth}
height={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 px-4 text-white'
className='selection-popup bg-gray-600 text-white'
triangleClassName='text-gray-600'
>
<div
className='selection-buttons flex items-center justify-between'
className={clsx(
'selection-buttons flex items-center justify-between p-2',
isVertical ? 'flex-col' : 'flex-row',
)}
style={{
height: popupHeight,
height: isVertical ? popupWidth : popupHeight,
}}
>
{buttons.map((button, index) => (
@@ -65,15 +71,27 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
width: `${isVertical ? popupHeight : popupWidth}px`,
height: `${isVertical ? popupWidth : popupHeight}px`,
...(isVertical
? {
left: `${
position.point.x +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'left' ? -1 : 1)
}px`,
top: `${position.point.y}px`,
}
: {
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}),
}}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
@@ -259,8 +259,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotPopupPos = getPopupPosition(
triangPos,
rect,
annotPopupWidth,
annotPopupHeight,
viewSettings.vertical ? annotPopupHeight : annotPopupWidth,
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
popupPadding,
);
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
@@ -351,7 +351,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
handleDismissPopupAndSelection();
setNotebookVisible(true);
if (!appService?.isMobile) {
setNotebookVisible(true);
}
};
const handleHighlight = (update = false) => {
@@ -404,11 +406,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (!selection || !selection.text) return;
const { sectionHref: href } = progress;
selection.href = href;
setShowAnnotPopup(false);
setHighlightOptionsVisible(false);
handleHighlight(true);
setNotebookVisible(true);
setNotebookNewAnnotation(selection);
handleHighlight(true);
handleDismissPopup();
};
const handleSearch = () => {
@@ -576,6 +577,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={buttons}
position={annotPopupPosition}
trianglePosition={trianglePosition}
@@ -12,12 +12,16 @@ const LANGUAGES = {
DE: 'German',
FR: 'French',
ES: 'Spanish',
ID: 'Indonesian',
IT: 'Italian',
EL: 'Greek',
PT: 'Portuguese',
NL: 'Dutch',
PL: 'Polish',
UK: 'Ukrainian',
RU: 'Russian',
AR: 'Arabic',
TR: 'Turkish',
ID: 'Indonesian',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
@@ -99,7 +103,11 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setTranslation(translatedText);
} catch (err) {
console.error(err);
setError(_('Unable to fetch the translation. Try again later.'));
if (!token) {
setError(_('Unable to fetch the translation. Please log in first and try again.'));
} else {
setError(_('Unable to fetch the translation. Try again later.'));
}
} finally {
setLoading(false);
}
@@ -9,6 +9,7 @@ const styles = ['highlight', 'underline', 'squiggly'] as HighlightStyle[];
const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
interface HighlightOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedStyle: HighlightStyle;
selectedColor: HighlightColor;
@@ -17,6 +18,7 @@ interface HighlightOptionsProps {
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
style,
isVertical,
selectedStyle: _selectedStyle,
selectedColor: _selectedColor,
onHandleHighlight,
@@ -44,13 +46,22 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
onHandleHighlight(true);
};
return (
<div className='highlight-options absolute flex items-center justify-between' style={style}>
<div className='flex gap-2' style={{ height: size28 }}>
<div
className={clsx(
'highlight-options absolute flex items-center justify-between',
isVertical ? 'flex-col' : 'flex-row',
)}
style={style}
>
<div
className={clsx('flex gap-2', isVertical ? 'flex-col' : 'flex-row')}
style={isVertical ? { width: size28 } : { height: size28 }}
>
{styles.map((style) => (
<button
key={style}
onClick={() => handleSelectStyle(style)}
className={`flex items-center justify-center rounded-full bg-gray-700 p-0`}
className='flex items-center justify-center rounded-full bg-gray-700 p-0'
style={{ width: size28, height: size28, minHeight: size28 }}
>
<div
@@ -78,8 +89,11 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
</div>
<div
className='flex items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'
style={{ height: size28 }}
className={clsx(
'flex items-center justify-center gap-2 rounded-3xl bg-gray-700',
isVertical ? 'flex-col py-2' : 'flex-row px-2',
)}
style={isVertical ? { width: size28 } : { height: size28 }}
>
{colors.map((color) => (
<button
@@ -20,7 +20,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
>
<button
onClick={handleClick}
className='my-2 flex h-8 min-h-8 w-8 items-center justify-center p-0'
className='flex h-8 min-h-8 w-8 items-center justify-center p-0'
>
<Icon />
</button>
@@ -12,43 +12,25 @@ import {
Theme,
themes,
} from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ThemeEditor from './ThemeEditor';
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
const _ = useTranslation();
const {
themeMode,
themeColor,
themeCode,
isDarkMode,
setThemeMode,
setThemeColor,
saveCustomTheme,
} = useThemeStore();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
useThemeStore();
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getViews, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
const [customThems, setCustomThemes] = useState<Theme[]>([]);
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
@@ -41,7 +41,10 @@ const FontDropdown: React.FC<DropdownProps> = ({
</button>
<ul
tabIndex={0}
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0'
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0',
'inline max-h-80 overflow-y-scroll',
)}
>
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from 'react';
import {
ANDROID_FONTS,
CJK_FONTS_PATTENS,
CJK_NAMES_PATTENS,
IOS_FONTS,
LINUX_FONTS,
MACOS_FONTS,
@@ -14,8 +16,7 @@ import {
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
@@ -65,8 +66,8 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
const fontFamilyOptions = [
{
@@ -105,6 +106,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
const [defaultFont, setDefaultFont] = useState(viewSettings.defaultFont!);
const [defaultCJKFont, setDefaultCJKFont] = useState(viewSettings.defaultCJKFont!);
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
@@ -121,49 +123,46 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultCJKFont', defaultCJKFont);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultCJKFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFontSize]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'minimumFontSize', minFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'fontWeight', fontWeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'serifFont', serifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serifFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'sansSerifFont', sansSerifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sansSerifFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'monospaceFont', monospaceFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monospaceFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'overrideFont', overrideFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideFont]);
@@ -234,6 +233,19 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
{(isCJKEnv() || view?.language.isCJK) && (
<FontFace
className='config-item-top'
family='serif'
label={_('CJK Font')}
options={sysFonts.filter(
(font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font),
)}
selected={defaultCJKFont}
onSelect={setDefaultCJKFont}
/>
)}
<div className='config-item'>
<span className=''>{_('Override Book Font')}</span>
<input

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