Compare commits

...

16 Commits

Author SHA1 Message Date
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
104 changed files with 874 additions and 477 deletions
Generated
+12 -11
View File
@@ -26,11 +26,11 @@ dependencies = [
"tauri-plugin-haptics",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-native-bridge",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-safari-auth",
"tauri-plugin-shell",
"tauri-plugin-sign-in-with-apple",
"tauri-plugin-single-instance",
@@ -5316,6 +5316,17 @@ dependencies = [
"time",
]
[[package]]
name = "tauri-plugin-native-bridge"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-oauth"
version = "2.0.0"
@@ -5381,16 +5392,6 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-safari-auth"
version = "0.1.0"
dependencies = [
"serde",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
+9
View File
@@ -9,9 +9,18 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.26",
"version": "0.9.27",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -260,5 +260,6 @@
"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": "تبديل منطقة التقليب بالنقر"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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": "クリックめくりエリアを入れ替え"
}
@@ -260,5 +260,6 @@
"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": "클릭-뒤집기 영역 교환"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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": "Переключить область клика для переворота"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"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": "Поміняти місцями область кліку для перевороту"
}
@@ -260,5 +260,6 @@
"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"
}
@@ -260,5 +260,6 @@
"Copied to clipboard": "已复制到剪贴板",
"Export Annotations": "导出笔记",
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷"
"LXGW WenKai GB Screen": "霞鹜文楷",
"Swap Click-to-Flip Area": "交换点击翻页区域"
}
@@ -260,5 +260,6 @@
"Copied to clipboard": "已複製到剪貼板",
"Export Annotations": "匯出筆記",
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷"
"LXGW WenKai GB Screen": "霞鶩文楷",
"Swap Click-to-Flip Area": "交換點擊翻頁區域"
}
+10
View File
@@ -1,5 +1,15 @@
{
"releases": {
"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": [
+1 -1
View File
@@ -46,7 +46,7 @@ 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-safari-auth = { path = "./plugins/tauri-plugin-safari-auth" }
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -75,7 +75,7 @@
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"safari-auth:default",
"native-bridge:default",
"deep-link:default"
]
}
@@ -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 {
@@ -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,9 @@
package com.readest.native_bridge
import android.util.Log
class NativeBridge {
fun auth_with_safari(value: String): String {
return ""
}
}
@@ -0,0 +1,28 @@
package com.readest.native_bridge
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 SafariAuthRequestArgs {
var authUrl: String? = null
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
@Command
fun auth_with_safari(invoke: Invoke) {
val args = invoke.parseArgs(SafariAuthRequestArgs::class.java)
val ret = JSObject()
ret.put("redirectUrl", implementation.auth_with_safari(args.authUrl ?: ""))
invoke.resolve(ret)
}
}
@@ -0,0 +1,8 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
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.
@@ -4,19 +4,15 @@ import Tauri
import UIKit
import WebKit
class RequestArgs: Decodable {
class SafariAuthRequestArgs: Decodable {
let authUrl: String
}
struct Response: Encodable {
let redirectUrl: String
}
class SafariAuthPlugin: Plugin {
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@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 +42,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()
}
}
@@ -16,7 +16,7 @@ Default permissions for the plugin
<tr>
<td>
`safari-auth:allow-auth-with-safari`
`native-bridge:allow-auth-with-safari`
</td>
<td>
@@ -29,7 +29,7 @@ Enables the auth_with_safari command without any pre-configured scope.
<tr>
<td>
`safari-auth:deny-auth-with-safari`
`native-bridge:deny-auth-with-safari`
</td>
<td>
@@ -1,13 +1,13 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::NativeBridgeExt;
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)
app.native_bridge().auth_with_safari(payload)
}
@@ -6,14 +6,14 @@ 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()))
) -> crate::Result<NativeBridge<R>> {
Ok(NativeBridge(app.clone()))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(AppHandle<R>);
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
impl<R: Runtime> SafariAuth<R> {
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(
&self,
_payload: SafariAuthRequest,
@@ -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)]
@@ -13,35 +13,36 @@ mod mobile;
mod commands;
mod error;
mod models;
mod platform;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::SafariAuth;
use desktop::NativeBridge;
#[cfg(mobile)]
use mobile::SafariAuth;
use mobile::NativeBridge;
/// 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>;
/// 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::SafariAuthExt<R> for T {
fn safari_auth(&self) -> &SafariAuth<R> {
self.state::<SafariAuth<R>>().inner()
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("safari-auth")
Builder::new("native-bridge")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.setup(|app, api| {
#[cfg(mobile)]
let safari_auth = mobile::init(app, api)?;
let native_bridge = mobile::init(app, api)?;
#[cfg(desktop)]
let safari_auth = desktop::init(app, api)?;
app.manage(safari_auth);
let native_bridge = desktop::init(app, api)?;
app.manage(native_bridge);
Ok(())
})
.build()
@@ -7,25 +7,24 @@ use tauri::{
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_safari_auth);
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<SafariAuth<R>> {
) -> crate::Result<NativeBridge<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
Ok(SafariAuth(handle))
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
Ok(NativeBridge(handle))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(PluginHandle<R>);
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> SafariAuth<R> {
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(
&self,
payload: SafariAuthRequest,
@@ -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()
}
}
+31 -23
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());
@@ -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>
);
}
+17 -14
View File
@@ -16,6 +16,7 @@ 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';
@@ -23,6 +24,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -39,7 +41,7 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = 'https://web.readest.com/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 }) => {
@@ -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);
@@ -82,6 +85,12 @@ export default function AuthPage() {
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');
@@ -139,9 +148,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 +248,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'),
@@ -308,7 +311,7 @@ export default function AuthPage() {
<div
ref={headerRef}
className={clsx(
'flex w-full items-center justify-between py-2 pe-6 ps-4',
'fixed flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
@@ -319,9 +322,9 @@ 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}
/>
)}
@@ -371,7 +374,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>
);
}
@@ -9,7 +9,7 @@ export interface SafariAuthResponse {
}
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
const result = await invoke<SafariAuthResponse>('plugin:safari-auth|auth_with_safari', {
const result = await invoke<SafariAuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
@@ -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';
@@ -152,7 +152,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);
},
});
@@ -68,7 +68,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',
)}
+3 -1
View File
@@ -366,6 +366,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')) {
@@ -510,7 +512,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',
@@ -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 {
@@ -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]);
@@ -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) => {
@@ -12,12 +12,18 @@ 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',
HI: 'Hindi',
ID: 'Indonesian',
VI: 'Vietnamese',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
@@ -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(
@@ -14,7 +14,6 @@ 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 { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
@@ -64,8 +63,7 @@ const FontFace = ({
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const fontFamilyOptions = [
@@ -121,49 +119,41 @@ 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, '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]);
@@ -46,54 +46,49 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paragraphMargin]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'lineHeight', lineHeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lineHeight]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'wordSpacing', wordSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordSpacing]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'letterSpacing', letterSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [letterSpacing]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'textIndent', textIndent);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textIndent]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'fullJustification', fullJustification);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullJustification]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'hyphenation', hyphenation);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hyphenation]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
if (marginPx === viewSettings.marginPx) return;
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
if (gapPercent === viewSettings.gapPercent) return;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
@@ -102,25 +97,29 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [gapPercent]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount);
if (maxColumnCount === viewSettings.maxColumnCount) return;
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
view?.renderer.setAttribute('max-column-count', maxColumnCount);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxColumnCount]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize);
if (maxInlineSize === viewSettings.maxInlineSize) return;
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize, false, false);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize);
if (maxBlockSize === viewSettings.maxBlockSize) return;
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize, false, false);
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
useEffect(() => {
if (writingMode === viewSettings.writingMode) return;
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
if (!writingMode.includes('vertical')) {
@@ -143,11 +142,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
@@ -159,6 +158,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [isScrolledMode]);
useEffect(() => {
if (doubleBorder === viewSettings.doubleBorder) return;
if (doubleBorder && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
@@ -167,16 +167,17 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder);
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor);
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
useEffect(() => {
if (showHeader === viewSettings.showHeader) return;
if (showHeader && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
@@ -189,11 +190,12 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader);
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
if (showFooter === viewSettings.showFooter) return;
if (showFooter && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
@@ -206,7 +208,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter);
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
@@ -21,6 +21,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
@@ -113,13 +114,12 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
const handleSelectUILang = (option: string) => {
viewSettings.uiLanguage = option;
saveViewSettings(envConfig, bookKey, 'uiLanguage', option);
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
i18n.changeLanguage(option ? option : navigator.language);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated);
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
@@ -129,12 +129,17 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [animated]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick);
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll);
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
@@ -197,6 +202,16 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Swap Click-to-Flip Area')}</span>
<input
type='checkbox'
className='toggle'
checked={swapClickArea}
disabled={isDisableClick}
onChange={() => setSwapClickArea(!swapClickArea)}
/>
</div>
</div>
</div>
</div>
@@ -102,14 +102,15 @@ const TTSControl = () => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
}
} catch (error) {
eventDispatcher.dispatch('toast', {
@@ -1,11 +1,13 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
type TTSPanelProps = {
@@ -36,7 +38,9 @@ const TTSPanel = ({
onGetVoiceId,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings, setViewSettings } = useReaderStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
@@ -54,7 +58,10 @@ const TTSPanel = ({
onSetRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
settings.globalViewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleSelectVoice = (voice: string) => {
@@ -7,6 +7,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
interface UseBookShortcutsProps {
@@ -90,6 +91,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
}
};
const showSearchBar = () => {
eventDispatcher.dispatch('search', { term: '' });
};
const zoomIn = () => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
@@ -129,6 +134,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleNotebook: toggleNotebook,
onToggleScrollMode: toggleScrollMode,
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onReloadPage: reloadPage,
onQuitApp: quitApp,
onGoLeft: goLeft,
@@ -51,9 +51,17 @@ export const useClickEvent = (
setHoveredBookKey(null);
}
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
viewRef.current?.goRight();
if (viewSettings.swapClickArea) {
viewRef.current?.goLeft();
} else {
viewRef.current?.goRight();
}
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
viewRef.current?.goLeft();
if (viewSettings.swapClickArea) {
viewRef.current?.goRight();
} else {
viewRef.current?.goLeft();
}
}
}
}
@@ -3,21 +3,29 @@ import { EnvConfigType } from '@/services/environment';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
export const saveViewSettings = async <K extends keyof ViewSettings>(
envConfig: EnvConfigType,
bookKey: string,
key: K,
value: ViewSettings[K],
skipGlobal?: boolean,
skipGlobal = false,
applyStyles = true,
) => {
const { settings, isFontLayoutSettingsGlobal, setSettings, saveSettings } =
useSettingsStore.getState();
const { getViewSettings, setViewSettings } = useReaderStore.getState();
const { getView, getViewSettings, setViewSettings } = useReaderStore.getState();
const { getConfig, saveConfig } = useBookDataStore.getState();
const viewSettings = getViewSettings(bookKey)!;
const config = getConfig(bookKey)!;
viewSettings[key] = value;
if (viewSettings[key] !== value) {
viewSettings[key] = value;
if (applyStyles) {
const view = getView(bookKey);
view?.renderer.setStyles?.(getStyles(viewSettings));
}
}
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal && !skipGlobal) {
+6 -4
View File
@@ -11,6 +11,7 @@ import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { QuotaType, UserPlan } from '@/types/user';
import { getStoragePlanData, getUserPlan } from '@/utils/access';
import { navigateToLibrary } from '@/utils/nav';
@@ -25,6 +26,7 @@ const ProfilePage = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user, logout } = useAuth();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [userPlan, setUserPlan] = useState<UserPlan>('free');
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
@@ -164,7 +166,7 @@ const ProfilePage = () => {
<div
ref={headerRef}
className={clsx(
'flex w-full items-center justify-between py-2 pe-6 ps-4',
'fixed flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
@@ -175,9 +177,9 @@ const ProfilePage = () => {
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={appService?.hasWindowBar}
showMaximize={appService?.hasWindowBar}
showClose={appService?.hasWindowBar}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onClose={handleGoBack}
/>
)}
+1 -1
View File
@@ -50,7 +50,7 @@ export const Toast = () => {
toastMessage && (
<div
className={clsx(
'toast toast-center toast-middle w-auto max-w-screen-sm',
'toast toast-center toast-middle z-50 w-auto max-w-screen-sm',
toastClassMap[toastType.current],
toastClassMap[toastType.current].includes('toast-top') &&
'pt-[calc(44px+env(safe-area-inset-top))]',
+19 -5
View File
@@ -6,7 +6,11 @@ interface UseAuthCallbackOptions {
refreshToken?: string | null;
login: (accessToken: string, user: User) => void;
navigate: (path: string) => void;
type?: string | null;
next?: string;
error?: string | null;
errorCode?: string | null;
errorDescription?: string | null;
}
export function handleAuthCallback({
@@ -14,22 +18,28 @@ export function handleAuthCallback({
refreshToken,
login,
navigate,
type,
next = '/',
error,
}: UseAuthCallbackOptions) {
async function finalizeSession() {
if (!accessToken || !refreshToken) {
console.error('No access token or refresh token');
if (error) {
navigate('/auth/error');
return;
}
const { error } = await supabase.auth.setSession({
if (!accessToken || !refreshToken) {
navigate('/library');
return;
}
const { error: err } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
if (error) {
console.error('Error setting session:', error);
if (err) {
console.error('Error setting session:', err);
navigate('/auth/error');
return;
}
@@ -39,6 +49,10 @@ export function handleAuthCallback({
} = await supabase.auth.getUser();
if (user) {
login(accessToken, user);
if (type === 'recovery') {
navigate('/auth/recovery');
return;
}
navigate(next);
} else {
console.error('Error fetching user data');
+18 -4
View File
@@ -1,21 +1,28 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import { useLibraryStore } from '@/store/libraryStore';
import { navigateToLibrary } from '@/utils/nav';
interface SingleInstancePayload {
args: string[];
cwd: string;
}
export function useOpenWithBooks() {
const router = useRouter();
const { setCheckOpenWithBooks } = useLibraryStore();
const listenedOpenWithBooks = useRef(false);
const handleOpenWithFileUrl = (url: string) => {
console.log('Handle Open with URL:', url);
let filePath = url;
if (filePath.startsWith('file://')) {
filePath = decodeURI(filePath.replace('file://', ''));
}
if (filePath.startsWith('/')) {
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
@@ -27,6 +34,13 @@ export function useOpenWithBooks() {
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
const unlistenDeeplink = getCurrentWindow().listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOpenWithFileUrl(args[1]);
}
});
const listenOpenWithFiles = async () => {
return await onOpenUrl((urls) => {
urls.forEach((url) => {
@@ -34,10 +48,10 @@ export function useOpenWithBooks() {
});
});
};
const unlisten = listenOpenWithFiles();
const unlistenOpenUrl = listenOpenWithFiles();
return () => {
unlisten.then((f) => f());
unlistenDeeplink.then((f) => f());
unlistenOpenUrl.then((f) => f());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+2
View File
@@ -126,6 +126,8 @@ export function useSync(bookKey?: string) {
console.error(err);
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
}
setSyncError(err.message || `Error pulling ${type}`);
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { createSupabaseClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { deleteObject } from '@/utils/r2';
import { deleteObject } from '@/utils/object';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -41,7 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
try {
await deleteObject(process.env['R2_BUCKET_NAME'] || '', fileKey);
await deleteObject(fileKey);
const { error: deleteError } = await supabase.from('files').delete().eq('id', fileRecord.id);
if (deleteError) {
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getDownloadSignedUrl } from '@/utils/r2';
import { getDownloadSignedUrl } from '@/utils/object';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -55,8 +55,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
try {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
const downloadUrl = await getDownloadSignedUrl(bucketName, fileKey, 1800);
const downloadUrl = await getDownloadSignedUrl(fileKey, 1800);
res.status(200).json({
downloadUrl,
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getStoragePlanData } from '@/utils/access';
import { getUploadSignedUrl } from '@/utils/r2';
import { getUploadSignedUrl } from '@/utils/object';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -40,13 +40,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ error: 'Insufficient storage quota', usage });
}
const objectKey = `${user.id}/${fileName}`;
const fileKey = `${user.id}/${fileName}`;
const supabase = createSupabaseClient(token);
const { data: existingRecord, error: fetchError } = await supabase
.from('files')
.select('*')
.eq('user_id', user.id)
.eq('file_key', objectKey)
.eq('file_key', fileKey)
.limit(1)
.single();
@@ -63,7 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
{
user_id: user.id,
book_hash: bookHash,
file_key: objectKey,
file_key: fileKey,
file_size: fileSize,
},
])
@@ -75,15 +75,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
const uploadUrl = await getUploadSignedUrl(
process.env['R2_BUCKET_NAME'] || '',
objectKey,
fileKey,
objSize,
1800,
);
res.status(200).json({
uploadUrl,
fileKey: objectKey,
fileKey,
usage: usage + fileSize,
quota,
});
+99 -79
View File
@@ -5,7 +5,8 @@ import { FileSystem, BaseDir } from '@/types/system';
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
import {
getDir,
getFilename,
getLocalBookFilename,
getRemoteBookFilename,
getBaseFilename,
getCoverFilename,
getConfigFilename,
@@ -31,7 +32,6 @@ import {
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
} from './constants';
import { isWebAppPlatform } from './environment';
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
@@ -184,11 +184,11 @@ export abstract class BaseAppService implements AppService {
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
if (saveBook && (!(await this.fs.exists(getLocalBookFilename(book), 'Books')) || overwrite)) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
await this.fs.copyFile(file, getFilename(book), 'Books');
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else {
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
}
}
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
@@ -218,12 +218,12 @@ export abstract class BaseAppService implements AppService {
}
async deleteBook(book: Book, includingUploaded = false): Promise<void> {
const fps = [getFilename(book), getCoverFilename(book)];
const localDeleteFps = (
await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null)))
).filter(Boolean) as string[];
const fps = [getRemoteBookFilename(book), getCoverFilename(book)];
const localDeleteFps = [getLocalBookFilename(book), getCoverFilename(book)];
for (const fp of localDeleteFps) {
await this.fs.removeFile(fp, 'Books');
if (await this.fs.exists(fp, 'Books')) {
await this.fs.removeFile(fp, 'Books');
}
}
for (const fp of fps) {
if (includingUploaded) {
@@ -243,38 +243,56 @@ export abstract class BaseAppService implements AppService {
}
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
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 localFullpath = `${this.localBooksDir}/${lfp}`;
await uploadFile(file, localFullpath, handleProgress, hash);
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
let uploaded = false;
const completedFiles = { count: 0 };
const fps = (
await Promise.all(
[getCoverFilename(book), getFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? fp : null,
),
)
).filter(Boolean) as string[];
if (!fps.includes(getFilename(book)) && book.url) {
let toUploadFpCount = 0;
const coverExist = await this.fs.exists(getCoverFilename(book), 'Books');
let bookFileExist = await this.fs.exists(getLocalBookFilename(book), 'Books');
if (coverExist) {
toUploadFpCount++;
}
if (bookFileExist) {
toUploadFpCount++;
}
if (!bookFileExist && book.url) {
// download the book from the URL
const fileobj = await new RemoteFile(book.url).open();
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
fps.push(getFilename(book));
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
bookFileExist = true;
}
const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress);
for (const fp of fps) {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fullpath = `${this.localBooksDir}/${fp}`;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
}
console.log('Uploading file:', fp);
await uploadFile(file, fullpath, handleProgress, book.hash);
const handleProgress = createProgressHandler(toUploadFpCount, completedFiles, onProgress);
if (coverExist) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
if (bookFileExist) {
const lfp = getLocalBookFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
if (uploaded) {
book.deletedAt = null;
book.updatedAt = Date.now();
@@ -285,62 +303,64 @@ export abstract class BaseAppService implements AppService {
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)];
async downloadCloudFile(lfp: string, cfp: string, handleProgress: ProgressHandler) {
console.log('Downloading file:', cfp, 'to', lfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
const result = await downloadFile(cfp, localFullpath, handleProgress);
try {
if (this.appPlatform === 'web') {
const fileobj = result as Blob;
await this.fs.writeFile(lfp, 'Books', await fileobj.arrayBuffer());
}
} catch {
console.log('Failed to download file:', cfp);
throw new Error('Failed to download file');
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
let bookDownloaded = false;
const completedFiles = { count: 0 };
const toDownloadFps = (
await Promise.all(
[getFilename(book), getCoverFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? null : fp,
),
)
).filter(Boolean) as string[];
const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress);
for (const fp of fps) {
let downloaded = false;
const existed = !toDownloadFps.includes(fp);
if (existed) {
downloaded = true;
} else {
console.log('Downloading file:', fp);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fullpath = `${this.localBooksDir}/${fp}`;
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
try {
const result = await downloadFile(cfp, fullpath, handleProgress);
if (isWebAppPlatform()) {
const fileobj = result as Blob;
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
downloaded = true;
} else {
downloaded = await this.fs.exists(fp, 'Books');
}
} catch {
if (fp === getCoverFilename(book)) {
console.log('Failed to download cover image:', fp);
} else {
throw new Error('Failed to download book file');
}
}
completedFiles.count++;
}
if (fp === getFilename(book)) {
bookDownloaded = downloaded;
}
let toDownloadFpCount = 0;
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books'));
const needDownBook = !onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'));
if (needDownCover) {
toDownloadFpCount++;
}
if (needDownBook) {
toDownloadFpCount++;
}
const handleProgress = createProgressHandler(toDownloadFpCount, completedFiles, onProgress);
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
}
if (needDownBook) {
const lfp = getLocalBookFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
const localFullpath = `${this.localBooksDir}/${lfp}`;
bookDownloaded = await this.fs.exists(localFullpath, 'Books');
completedFiles.count++;
}
// some books may not have cover image, so we need to check if the book is downloaded
if (bookDownloaded) {
if (bookDownloaded || (!onlyCover && !needDownBook)) {
book.downloadedAt = Date.now();
}
}
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
let file: File;
const fp = getFilename(book);
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
// TODO: fix random access for android
if (this.appPlatform === 'web' || getOSPlatform() === 'android') {
@@ -371,7 +391,7 @@ export abstract class BaseAppService implements AppService {
}
async fetchBookDetails(book: Book, settings: SystemSettings) {
const fp = getFilename(book);
const fp = getLocalBookFilename(book);
if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) {
await this.downloadBook(book);
}
+2 -1
View File
@@ -75,6 +75,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
gapPercent: 5,
scrolled: false,
disableClick: false,
swapClickArea: false,
continuousScroll: false,
maxColumnCount: 2,
maxInlineSize: getDefaultMaxInlineSize(),
@@ -122,7 +123,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.0,
ttsRate: 1.3,
ttsVoice: '',
};
@@ -3,6 +3,7 @@ import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
import { TTSUtils } from './TTSUtils';
export class EdgeTTSClient implements TTSClient {
#rate = 1.0;
@@ -61,16 +62,12 @@ export class EdgeTTSClient implements TTSClient {
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const ssmlLang = parseSSMLLang(ssml) || 'en';
let lang = ssmlLang;
if (lang === 'en' && /[\p{Script=Han}]/u.test(ssml)) {
lang = 'zh';
}
const lang = parseSSMLLang(ssml) || 'en';
let voiceId = 'en-US-AriaNeural';
if (!this.#voice || ssmlLang !== lang) {
const voices = await this.getVoices(lang);
this.#voice = voices[0] ? voices[0] : this.#voices.find((v) => v.id === voiceId) || null;
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang);
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
this.#voice = preferredVoice ? preferredVoice : (await this.getVoices(lang))[0] || null;
this.#currentVoiceLang = lang;
}
if (this.#voice) {
@@ -78,15 +75,32 @@ export class EdgeTTSClient implements TTSClient {
}
if (preload) {
for (const mark of marks) {
// preload the first 2 marks immediately and the rest in the background
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
const mark = marks[i]!;
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
console.warn('Error preloading:', err);
console.warn('Error preloading mark', i, err);
});
}
if (marks.length > maxImmediate) {
(async () => {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId));
} catch (err) {
console.warn('Error preloading mark (bg)', i, err);
}
}
})();
}
yield {
code: 'end',
message: 'Preload finished',
};
return;
} else {
await this.stopInternal();
@@ -2,6 +2,7 @@ import { FoliateView, TTSGranularity } from '@/types/view';
import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient';
import { WebSpeechClient } from './WebSpeechClient';
import { EdgeTTSClient } from './EdgeTTSClient';
import { TTSUtils } from './TTSUtils';
type TTSState =
| 'stopped'
@@ -19,6 +20,7 @@ export class TTSController extends EventTarget {
#currentSpeakAbortController: AbortController | null = null;
#currentSpeakPromise: Promise<void> | null = null;
ttsLang: string = '';
ttsRate: number = 1.0;
ttsClient: TTSClient;
ttsWebClient: TTSClient;
@@ -67,7 +69,7 @@ export class TTSController extends EventTarget {
if (!tts) return;
let preloaded = 0;
for (let i = 0; i < count; i++) {
const ssml = tts.next();
const ssml = this.#preprocessSSML(tts.next());
this.preloadSSML(ssml);
if (ssml) preloaded++;
}
@@ -76,6 +78,16 @@ export class TTSController extends EventTarget {
}
}
#preprocessSSML(ssml?: string) {
if (!ssml) return;
ssml = ssml
.replace(/[–—]/g, ',')
.replace(/\.{3,}/g, '<break time="400ms"/>')
.replace(/·/g, '<break time="200ms"/>');
return ssml;
}
async #speak(ssml: string | undefined | Promise<string>) {
await this.stop();
this.#currentSpeakAbortController = new AbortController();
@@ -85,7 +97,7 @@ export class TTSController extends EventTarget {
try {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
ssml = this.#preprocessSSML(await ssml);
await this.preloadSSML(ssml);
if (!ssml) {
this.#nossmlCnt++;
@@ -205,6 +217,10 @@ export class TTSController extends EventTarget {
}
}
async setLang(lang: string) {
this.ttsLang = lang;
}
async setRate(rate: number) {
this.state = 'setrate-paused';
this.ttsRate = rate;
@@ -225,9 +241,11 @@ export class TTSController extends EventTarget {
if (useEdgeTTS) {
this.ttsClient = this.ttsEdgeClient;
await this.ttsClient.setRate(this.ttsRate);
TTSUtils.setPreferredVoice('edge-tts', this.ttsLang, voiceId);
} else {
this.ttsClient = this.ttsWebClient;
await this.ttsClient.setRate(this.ttsRate);
TTSUtils.setPreferredVoice('web-speech', this.ttsLang, voiceId);
}
await this.ttsClient.setVoice(voiceId);
}
@@ -0,0 +1,27 @@
export class TTSUtils {
private static readonly LOCAL_STORAGE_KEY = 'ttsPreferredVoices';
private static normalizeLanguage(language: string): string {
if (!language) return 'n/a';
return language.toLowerCase().slice(0, 2);
}
static setPreferredVoice(engine: string, language: string, voiceId: string): void {
if (!engine || !language || !voiceId) return;
const preferences = this.getPreferences();
const lang = this.normalizeLanguage(language);
preferences[`${engine}-${lang}`] = voiceId;
localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(preferences));
}
static getPreferredVoice(engine: string, language: string): string | null {
const preferences = this.getPreferences();
const lang = this.normalizeLanguage(language);
return preferences[`${engine}-${lang}`] || null;
}
private static getPreferences(): Record<string, string> {
const storedPreferences = localStorage.getItem(this.LOCAL_STORAGE_KEY);
return storedPreferences ? JSON.parse(storedPreferences) : {};
}
}
@@ -3,6 +3,7 @@ import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
import { TTSUtils } from './TTSUtils';
const BLACKLISTED_VOICES = [
'Albert',
@@ -214,15 +215,14 @@ export class WebSpeechClient implements TTSClient {
// no need to preload for web speech
if (preload) return;
const ssmlLang = parseSSMLLang(ssml) || 'en';
let lang = ssmlLang;
if (lang === 'en' && /[\p{Script=Han}]/u.test(ssml)) {
lang = 'zh';
}
if (!this.#voice || ssmlLang !== lang) {
const voices = await this.getVoices(lang);
const voiceId = voices[0]?.id ?? '';
this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null;
const lang = parseSSMLLang(ssml) || 'en';
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang);
const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId);
const voiceId = (await this.getVoices(lang))[0]?.id ?? '';
this.#voice = preferredVoice
? preferredVoice
: this.#voices.find((v) => v.voiceURI === voiceId) || null;
this.#currentVoiceLang = lang;
}
for await (const ev of speakWithMarks(
+1
View File
@@ -67,6 +67,7 @@ export interface BookLayout {
gapPercent: number;
scrolled: boolean;
disableClick: boolean;
swapClickArea: boolean;
continuousScroll: boolean;
maxColumnCount: number;
maxInlineSize: number;
+12 -1
View File
@@ -1,6 +1,7 @@
import { EXTS } from '@/libs/document';
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, makeSafeFilename } from './misc';
import { getStorageType } from './object';
export const getDir = (book: Book) => {
return `${book.hash}`;
@@ -8,7 +9,17 @@ export const getDir = (book: Book) => {
export const getLibraryFilename = () => {
return 'library.json';
};
export const getFilename = (book: Book) => {
export const getRemoteBookFilename = (book: Book) => {
// S3 storage: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/object-keys.html
if (getStorageType() === 'r2') {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
} else if (getStorageType() === 's3') {
return `${book.hash}/${book.hash}.${EXTS[book.format]}`;
} else {
return '';
}
};
export const getLocalBookFilename = (book: Book) => {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
};
export const getCoverFilename = (book: Book) => {
+53
View File
@@ -0,0 +1,53 @@
import { s3Storage } from './s3';
import { r2Storage } from './r2';
type ObjectStorageType = 'r2' | 's3';
export const getStorageType = (): ObjectStorageType => {
// TODO: do not expose storage type to client
if (process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE']) {
return process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'] as ObjectStorageType;
} else {
return 'r2';
}
};
export const getDownloadSignedUrl = async (
fileKey: string,
expiresIn: number,
) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
}
};
export const getUploadSignedUrl = async (
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
}
};
export const deleteObject = async (fileKey: string) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.deleteObject(bucketName, fileKey);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.deleteObject(bucketName, fileKey);
}
};
+54 -52
View File
@@ -1,60 +1,62 @@
import { AwsClient } from 'aws4fetch';
const getR2Client = () => {
return new AwsClient({
service: 's3',
region: process.env['R2_REGION'] || 'auto',
accessKeyId: process.env['R2_ACCESS_KEY_ID']!,
secretAccessKey: process.env['R2_SECRET_ACCESS_KEY']!,
});
};
export const r2Storage = {
getR2Client: () => {
return new AwsClient({
service: 's3',
region: process.env['R2_REGION'] || 'auto',
accessKeyId: process.env['R2_ACCESS_KEY_ID']!,
secretAccessKey: process.env['R2_SECRET_ACCESS_KEY']!,
});
},
const getR2Url = () => {
const R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']!;
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
};
getR2Url: () => {
const R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']!;
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
},
export const getDownloadSignedUrl = async (
bucketName: string,
fileKey: string,
expiresIn: number,
) => {
return (
await getR2Client().sign(
new Request(`${getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}`),
{
aws: { signQuery: true },
},
)
).url.toString();
};
export const getUploadSignedUrl = async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
return (
await getR2Client().sign(
new Request(
`${getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=content-length`,
getDownloadSignedUrl: async (
bucketName: string,
fileKey: string,
expiresIn: number,
) => {
return (
await r2Storage.getR2Client().sign(
new Request(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}`),
{
method: 'PUT',
headers: {
'Content-Length': contentLength.toString(),
},
aws: { signQuery: true },
},
),
{
aws: { signQuery: true },
},
)
).url.toString();
};
)
).url.toString();
},
export const deleteObject = async (bucketName: string, fileKey: string) => {
return await getR2Client().fetch(`${getR2Url()}/${bucketName}/${fileKey}`, {
method: 'DELETE',
});
getUploadSignedUrl: async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
return (
await r2Storage.getR2Client().sign(
new Request(
`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=content-length`,
{
method: 'PUT',
headers: {
'Content-Length': contentLength.toString(),
},
},
),
{
aws: { signQuery: true },
},
)
).url.toString();
},
deleteObject: async (bucketName: string, fileKey: string) => {
return await r2Storage.getR2Client().fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
method: 'DELETE',
});
},
};

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