refactor: read local file with fs plugin for Tauri App, closes #553 and closes #489 (#785)

This commit is contained in:
Huang Xin
2025-04-01 20:26:26 +08:00
committed by GitHub
parent 39fb7f759e
commit 7ccf3d0632
46 changed files with 1164 additions and 790 deletions
Generated
+659 -667
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" }
@@ -152,7 +152,6 @@
"Text Indent": "مسافة بادئة للنص",
"Paragraph Margin": "هامش الفقرة",
"Override Book Layout": "تجاوز تخطيط الكتاب",
"Add to Group": "إضافة إلى مجموعة",
"Untitled Group": "مجموعة بدون عنوان",
"Group Books": "تجميع الكتب",
"Remove From Group": "إزالة من المجموعة",
@@ -265,5 +264,6 @@
"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.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى."
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -152,7 +152,6 @@
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
@@ -265,5 +264,6 @@
"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.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά."
"Unable to fetch the translation. Please log in first and try again.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά.",
"Group": "Ομάδα"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -152,7 +152,6 @@
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
@@ -265,5 +264,6 @@
"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.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।"
"Unable to fetch the translation. Please log in first and try again.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।",
"Group": "समूह"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"Unable to fetch the translation. Please log in first and try again.": "Impossibile recuperare la traduzione. Accedi prima e riprova.",
"Group": "Gruppo"
}
@@ -152,7 +152,6 @@
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
@@ -265,5 +264,6 @@
"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.": "翻訳を取得できません。まずログインして再試行してください。"
"Unable to fetch the translation. Please log in first and try again.": "翻訳を取得できません。まずログインして再試行してください。",
"Group": "グループ"
}
@@ -152,7 +152,6 @@
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
@@ -265,5 +264,6 @@
"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.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요."
"Unable to fetch the translation. Please log in first and try again.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요.",
"Group": "그룹"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -152,7 +152,6 @@
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
@@ -265,5 +264,6 @@
"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.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова."
"Unable to fetch the translation. Please log in first and try again.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова.",
"Group": "Группа"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -152,7 +152,6 @@
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
@@ -265,5 +264,6 @@
"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.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз."
"Unable to fetch the translation. Please log in first and try again.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз.",
"Group": "Група"
}
@@ -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",
@@ -265,5 +264,6 @@
"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."
"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"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
@@ -265,5 +264,6 @@
"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.": "无法获取翻译。请登录后重试。"
"Unable to fetch the translation. Please log in first and try again.": "无法获取翻译。请登录后重试。",
"Group": "分组"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
@@ -265,5 +264,6 @@
"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.": "無法獲取翻譯。請先登入然後重試。"
"Unable to fetch the translation. Please log in first and try again.": "無法獲取翻譯。請先登入然後重試。",
"Group": "分組"
}
+9 -12
View File
@@ -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.0", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.0"
tauri-plugin-log = "2.3.1"
tauri-plugin-fs = "2.2.0"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.2"
tauri-plugin-shell = "2.2.0"
tauri-plugin-process = "2.2.0"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.3"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"
@@ -63,6 +63,7 @@
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"log:default",
"shell:default",
"process:default",
"process:allow-exit",
@@ -1,18 +1,26 @@
package com.readest.native_bridge
import android.app.Activity
import android.net.Uri
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 SafariAuthRequestArgs {
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()
@@ -25,4 +33,31 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
ret.put("redirectUrl", implementation.auth_with_safari(args.authUrl ?: ""))
invoke.resolve(ret)
}
@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)
}
}
@@ -1,8 +1,8 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
const COMMANDS: &[&str] = &["auth_with_safari", "copy_uri_to_path"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -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"]
@@ -3,6 +3,7 @@
Default permissions for the plugin
- `allow-auth-with-safari`
- `allow-copy-uri-to-path`
## Permission Table
@@ -36,6 +37,32 @@ Enables the auth_with_safari command without any pre-configured scope.
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>
</table>
@@ -1,3 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari"]
permissions = ["allow-auth-with-safari", "allow-copy-uri-to-path"]
@@ -304,6 +304,16 @@
"type": "string",
"const": "deny-auth-with-safari"
},
{
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "allow-copy-uri-to-path"
},
{
"description": "Denies the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "deny-copy-uri-to-path"
},
{
"description": "Default permissions for the plugin",
"type": "string",
@@ -11,3 +11,11 @@ pub(crate) async fn auth_with_safari<R: Runtime>(
) -> Result<SafariAuthResponse> {
app.native_bridge().auth_with_safari(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)
}
@@ -20,4 +20,8 @@ impl<R: Runtime> NativeBridge<R> {
) -> crate::Result<SafariAuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -36,7 +36,10 @@ impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.invoke_handler(tauri::generate_handler![
commands::auth_with_safari,
commands::copy_uri_to_path,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_bridge = mobile::init(app, api)?;
@@ -34,3 +34,11 @@ impl<R: Runtime> NativeBridge<R> {
.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)
}
}
@@ -11,3 +11,17 @@ pub struct SafariAuthRequest {
pub struct SafariAuthResponse {
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>,
}
+5 -8
View File
@@ -217,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());
@@ -210,7 +210,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
<div>{_('Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
+4 -4
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';
@@ -311,7 +311,7 @@ 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
@@ -332,7 +332,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
return (await appService?.selectFiles(_('Select Books'), SUPPORTED_FILE_EXTS)) || [];
};
const selectFilesWeb = () => {
@@ -453,7 +453,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];
+43 -35
View File
@@ -14,8 +14,8 @@ import {
INIT_BOOK_CONFIG,
formatTitle,
formatAuthors,
getFilename,
} from '@/utils/book';
import { RemoteFile } from '@/utils/file';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
import {
@@ -32,7 +32,7 @@ import {
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
} from './constants';
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
@@ -117,6 +117,12 @@ export abstract class BaseAppService implements AppService {
}
this.localBooksDir = settings.localBooksDir;
this.fs.getPrefix = (baseDir: BaseDir) => {
if (baseDir === 'Books') {
return this.localBooksDir;
}
return null;
};
return settings;
}
@@ -127,7 +133,12 @@ export abstract class BaseAppService implements AppService {
}
async importBook(
file: string | File, // file path/url or file object
// file might be:
// 1. absolute path for local file
// 2. remote url
// 3. content provider uri
// 4. File object from browsers
file: string | File,
books: Book[],
saveBook: boolean = true,
saveCover: boolean = true,
@@ -146,8 +157,8 @@ export abstract class BaseAppService implements AppService {
try {
if (typeof file === 'string') {
filename = file;
fileobj = await new RemoteFile(this.fs.getURL(file), file).open();
filename = getFilename(file);
fileobj = await this.fs.openFile(file, 'None');
} else {
filename = file.name;
fileobj = file;
@@ -185,6 +196,12 @@ export abstract class BaseAppService implements AppService {
downloadedAt: Date.now(),
updatedAt: Date.now(),
};
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
@@ -193,10 +210,14 @@ export abstract class BaseAppService implements AppService {
!transient &&
(!(await this.fs.exists(getLocalBookFilename(book), 'Books')) || overwrite)
) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
if (typeof file === 'string' && isContentURI(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else if (filename.endsWith('.txt')) {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
} else if (typeof file === 'string' && !isValidURL(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
}
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
@@ -209,19 +230,18 @@ export abstract class BaseAppService implements AppService {
if (!existingBook) {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
} else {
existingBook.title = book.title;
existingBook.author = book.author;
if (transient) {
existingBook.filePath = filename;
}
}
if (transient) {
book.filePath = filename;
}
if (typeof file === 'string' && isValidURL(file)) {
book.url = file;
// update file links with url or path or content uri
if (typeof file === 'string') {
if (isValidURL(file)) {
book.url = file;
if (existingBook) existingBook.url = file;
}
if (transient) {
book.filePath = file;
if (existingBook) existingBook.filePath = file;
}
}
book.coverImageUrl = await this.generateCoverImageUrl(book);
@@ -258,14 +278,8 @@ export abstract class BaseAppService implements AppService {
}
async uploadFileToCloud(lfp: string, cfp: string, handleProgress: ProgressHandler, hash: string) {
let file: File;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(lfp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${lfp}`), cfp).open();
}
console.log('Uploading file:', lfp, 'to', cfp);
const file = await this.fs.openFile(lfp, 'Books', cfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
await uploadFile(file, localFullpath, handleProgress, hash);
}
@@ -284,7 +298,7 @@ export abstract class BaseAppService implements AppService {
}
if (!bookFileExist && book.url) {
// download the book from the URL
const fileobj = await new RemoteFile(book.url).open();
const fileobj = await this.fs.openFile(book.url, 'None');
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
bookFileExist = true;
}
@@ -376,17 +390,11 @@ export abstract class BaseAppService implements AppService {
let file: File;
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
// TODO: fix random access for android
if (this.appPlatform === 'web' || getOSPlatform() === 'android') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], fp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
}
file = await this.fs.openFile(fp, 'Books');
} else if (book.filePath) {
file = await new RemoteFile(this.fs.getURL(book.filePath), fp).open();
file = await this.fs.openFile(book.filePath, 'None');
} else if (book.url) {
file = await new RemoteFile(book.url).open();
file = await this.fs.openFile(book.url, 'None');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
@@ -1,6 +1,7 @@
import {
exists,
mkdir,
open as openFile,
readTextFile,
readFile,
writeTextFile,
@@ -9,16 +10,19 @@ import {
remove,
copyFile,
BaseDirectory,
WriteFileOptions,
} from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
import { open, message } from '@tauri-apps/plugin-dialog';
import { open as openDialog, message } from '@tauri-apps/plugin-dialog';
import { join, appDataDir } from '@tauri-apps/api/path';
import { type as osType } from '@tauri-apps/plugin-os';
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { isValidURL } from '@/utils/misc';
import { isContentURI, isValidURL } from '@/utils/misc';
import { getCoverFilename, getFilename } from '@/utils/book';
import { copyURIToPath } from '@/utils/bridge';
import { NativeFile, RemoteFile } from '@/utils/file';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
@@ -47,6 +51,12 @@ const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDi
fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`,
base,
};
case 'None':
return {
baseDir: 0,
fp,
base,
};
default:
return {
baseDir: BaseDirectory.Temp,
@@ -64,9 +74,45 @@ export const nativeFileSystem: FileSystem = {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
},
async openFile(path: string, base: BaseDir, name?: string) {
const { fp, baseDir } = resolvePath(path, base);
const fname = name || getFilename(fp);
if (isValidURL(path)) {
return await new RemoteFile(path, name).open();
} else if (isContentURI(path)) {
return await new NativeFile(fp, fname, base ? baseDir : null).open();
} else {
const prefix = this.getPrefix(base);
if (prefix && OS_TYPE !== 'android') {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
// Once the bug is resolved, we should switch back to using NativeFile.
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
const absolutePath = await join(prefix, path);
return await new RemoteFile(this.getURL(absolutePath), path).open();
} else {
return await new NativeFile(fp, fname, base ? baseDir : null).open();
}
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
if (isContentURI(srcPath)) {
const prefix = this.getPrefix(base);
if (!prefix) {
throw new Error('Invalid base directory');
}
const res = await copyURIToPath({
uri: srcPath,
dst: `${prefix}/${dstPath}`,
});
if (!res.success) {
console.error('Failed to copy file:', res);
throw new Error('Failed to copy file');
}
} else {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
}
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp, baseDir } = resolvePath(path, base);
@@ -75,12 +121,32 @@ export const nativeFileSystem: FileSystem = {
? (readTextFile(fp, base && { baseDir }) as Promise<string>)
: ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer);
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
// NOTE: this could be very slow for large files and might block the UI thread
// so do not use this for large files
const { fp, baseDir } = resolvePath(path, base);
return typeof content === 'string'
? writeTextFile(fp, content, base && { baseDir })
: writeFile(fp, new Uint8Array(content), base && { baseDir });
if (typeof content === 'string') {
return writeTextFile(fp, content, base && { baseDir });
} else if (content instanceof File) {
const writeOptions = { write: true, create: true, baseDir } as WriteFileOptions;
// TODO: use writeFile directly when @tauri-apps/plugin-fs@2.2.1 is released
// return writeFile(fp, content.stream(), base && writeOptions);
const file = await openFile(fp, base && writeOptions);
const reader = content.stream().getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await file.write(value);
}
} finally {
reader.releaseLock();
await file.close();
}
} else {
return writeFile(fp, new Uint8Array(content), base && { baseDir });
}
},
async removeFile(path: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(path, base);
@@ -118,6 +184,9 @@ export const nativeFileSystem: FileSystem = {
return false;
}
},
getPrefix() {
return null;
},
};
export class NativeAppService extends BaseAppService {
@@ -145,7 +214,7 @@ export class NativeAppService extends BaseAppService {
}
async selectFiles(name: string, extensions: string[]): Promise<string[]> {
const selected = await open({
const selected = await openDialog({
multiple: true,
filters: [{ name, extensions }],
});
@@ -2,6 +2,7 @@ import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
@@ -53,6 +54,14 @@ const indexedDBFileSystem: FileSystem = {
return path;
}
},
async openFile(path: string, base: BaseDir) {
if (isValidURL(path)) {
return await new RemoteFile(path).open();
} else {
const content = await this.readFile(path, base, 'binary');
return new File([content], path);
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = resolvePath(dstPath, base);
const db = await openIndexedDB();
@@ -174,6 +183,9 @@ const indexedDBFileSystem: FileSystem = {
request.onerror = () => reject(request.error);
});
},
getPrefix() {
return null;
},
};
export class WebAppService extends BaseAppService {
+3 -1
View File
@@ -10,14 +10,16 @@ export type ToastType = 'info' | 'warning' | 'error';
export interface FileSystem {
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise<void>;
removeFile(path: string, base: BaseDir): Promise<void>;
readDir(path: string, base: BaseDir): Promise<{ path: string; isDir: boolean }[]>;
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
removeDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
exists(path: string, base: BaseDir): Promise<boolean>;
getPrefix(base: BaseDir): string | null;
}
export interface AppService {
+10 -1
View File
@@ -1,6 +1,6 @@
import { EXTS } from '@/libs/document';
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, makeSafeFilename } from './misc';
import { getUserLang, isContentURI, isValidURL, makeSafeFilename } from './misc';
import { getStorageType } from './object';
export const getDir = (book: Book) => {
@@ -31,6 +31,15 @@ export const getConfigFilename = (book: Book) => {
export const isBookFile = (filename: string) => {
return Object.values(EXTS).includes(filename.split('.').pop()!);
};
export const getFilename = (fileOrUri: string) => {
if (isValidURL(fileOrUri) || isContentURI(fileOrUri)) {
fileOrUri = decodeURI(fileOrUri);
}
const normalizedPath = fileOrUri.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
const lastPart = parts.pop()!;
return lastPart.split('?')[0]!;
};
export const getBaseFilename = (filename: string) => {
const normalizedPath = filename.replace(/\\/g, '/');
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
+19
View File
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core';
export interface CopyURIRequest {
uri: string;
dst: string;
}
export interface CopyURIResponse {
success: boolean;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
});
return result;
}
+132 -5
View File
@@ -1,6 +1,7 @@
import { FileHandle, open, BaseDirectory, SeekMode } from '@tauri-apps/plugin-fs';
import { getOSPlatform } from './misc';
class RemoteBlobSlice extends Blob {
class DeferredBlob extends Blob {
#dataPromise: Promise<ArrayBuffer>;
#type: string;
@@ -49,6 +50,128 @@ class RemoteBlobSlice extends Blob {
}
}
export class NativeFile extends File {
#handle: FileHandle | null = null;
#fp: string;
#name: string;
#baseDir: BaseDirectory | null;
#lastModified: number = 0;
#size: number = -1;
#type: string = '';
constructor(fp: string, name?: string, baseDir: BaseDirectory | null = null, type = '') {
super([], name || fp, { type });
this.#fp = fp;
this.#baseDir = baseDir;
this.#name = name || fp;
}
async open() {
this.#handle = await open(this.#fp, this.#baseDir ? { baseDir: this.#baseDir } : undefined);
const stats = await this.#handle.stat();
this.#size = stats.size;
this.#lastModified = stats.mtime ? stats.mtime.getTime() : Date.now();
return this;
}
override get name() {
return this.#name;
}
override get type() {
return this.#type;
}
override get size() {
return this.#size;
}
override get lastModified() {
return this.#lastModified;
}
async stat() {
return this.#handle?.stat();
}
async close(): Promise<void> {
return this.#handle?.close();
}
async seek(offset: number, whence: SeekMode): Promise<number> {
if (!this.#handle) {
throw new Error('File handle is not open');
}
return this.#handle.seek(offset, whence);
}
// exclusive reading of the end: [start, end)
async readData(start: number, end: number): Promise<ArrayBuffer> {
if (!this.#handle) {
throw new Error('File handle is not open');
}
start = Math.max(0, start);
end = Math.max(start, Math.min(this.size, end));
await this.#handle.seek(start, SeekMode.Start);
const buffer = new Uint8Array(end - start);
await this.#handle.read(buffer);
return buffer.buffer;
}
override slice(start = 0, end = this.size, contentType = this.type): Blob {
// console.log(`Slicing: ${start}-${end}, size: ${end - start}`);
const dataPromise = this.readData(start, end);
return new DeferredBlob(dataPromise, contentType);
}
override stream(): ReadableStream<Uint8Array> {
const CHUNK_SIZE = 1024 * 1024;
let offset = 0;
return new ReadableStream<Uint8Array>({
pull: async (controller) => {
if (!this.#handle) {
controller.error(new Error('File handle is not open'));
return;
}
if (offset >= this.size) {
controller.close();
return;
}
const end = Math.min(offset + CHUNK_SIZE, this.size);
const buffer = new Uint8Array(end - offset);
await this.#handle.seek(offset, SeekMode.Start);
const bytesRead = await this.#handle.read(buffer);
if (bytesRead === null || bytesRead === 0) {
controller.close();
return;
}
controller.enqueue(buffer.subarray(0, bytesRead));
offset += bytesRead;
},
cancel: async () => {
await this.#handle?.close();
},
});
}
override async text() {
const blob = this.slice(0, this.size);
return blob.text();
}
override async arrayBuffer() {
const blob = this.slice(0, this.size);
return blob.arrayBuffer();
}
}
export class RemoteFile extends File {
url: string;
#name: string;
@@ -61,10 +184,11 @@ export class RemoteFile extends File {
static MAX_CACHE_CHUNK_SIZE = 1024 * 128;
static MAX_CACHE_ITEMS_SIZE: number = 10;
constructor(url: string, name = 'remote-file', type = '', lastModified = Date.now()) {
super([], name, { type: type, lastModified });
constructor(url: string, name?: string, type = '', lastModified = Date.now()) {
const basename = url.split('/').pop() || 'remote-file';
super([], name || basename, { type, lastModified });
this.url = url;
this.#name = name;
this.#name = name || basename;
this.#type = type;
this.#lastModified = lastModified;
}
@@ -114,6 +238,8 @@ export class RemoteFile extends File {
}
}
async close(): Promise<void> {}
async fetchRangePart(start: number, end: number) {
start = Math.max(0, start);
end = Math.min(this.size - 1, end);
@@ -125,6 +251,7 @@ export class RemoteFile extends File {
return response.arrayBuffer();
}
// inclusive reading of the end: [start, end]
async fetchRange(start: number, end: number): Promise<ArrayBuffer> {
const rangeSize = end - start + 1;
const MAX_RANGE_LEN = 1024 * 1000;
@@ -194,7 +321,7 @@ export class RemoteFile extends File {
// console.log(`Slicing: ${start}-${end}, size: ${end - start}`);
const dataPromise = this.fetchRange(start, end - 1);
return new RemoteBlobSlice(dataPromise, contentType);
return new DeferredBlob(dataPromise, contentType);
}
override async text() {
+4
View File
@@ -59,6 +59,10 @@ export const getOSPlatform = () => {
return '';
};
export const isContentURI = (uri: string) => {
return uri.startsWith('content://');
};
export const isValidURL = (url: string, allowedSchemes: string[] = ['http', 'https']) => {
try {
const { protocol } = new URL(url);
+1 -1
View File
@@ -11231,7 +11231,7 @@ snapshots:
is-weakset@2.0.3:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
isarray@1.0.0: {}