This commit is contained in:
Generated
+32
-7
@@ -14,6 +14,7 @@ version = "0.2.2"
|
||||
dependencies = [
|
||||
"block",
|
||||
"cocoa",
|
||||
"discord-rich-presence",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -697,7 +698,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1193,6 +1194,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ead3c5edc7e048c317c6fc4a7e24aff0c7e4c136918e2ba38106a385b2cc53a5"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"thiserror 2.0.17",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -4393,7 +4409,7 @@ dependencies = [
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4598,7 +4614,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5369,7 +5385,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tray-icon",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
@@ -5419,7 +5435,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5913,7 +5929,7 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -6508,6 +6524,15 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
@@ -7464,7 +7489,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"uuid 1.19.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros",
|
||||
|
||||
@@ -28,3 +28,5 @@ S3_ACCESS_KEY_ID=PLACE_HOLDER
|
||||
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
|
||||
S3_BUCKET_NAME=PLACE_HOLDER
|
||||
S3_REGION=PLACE_HOLDER
|
||||
|
||||
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"lint": "eslint .",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"tauri": "tauri",
|
||||
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
|
||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/{openjpeg.wasm,qcms_bg.wasm}\" ./public/vendor/pdfjs",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -928,5 +928,7 @@
|
||||
"Export": "تصدير",
|
||||
"Set Timeout": "تعيين المهلة",
|
||||
"Select Voice": "اختر الصوت",
|
||||
"Toggle Sticky Bottom TTS Bar": "تبديل شريط TTS الثابت"
|
||||
"Toggle Sticky Bottom TTS Bar": "تبديل شريط TTS الثابت",
|
||||
"Display what I'm reading on Discord": "عرض ما أقرأه على Discord",
|
||||
"Show on Discord": "عرض على Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "রপ্তানি",
|
||||
"Set Timeout": "টাইমআউট সেট করুন",
|
||||
"Select Voice": "ভয়েস নির্বাচন করুন",
|
||||
"Toggle Sticky Bottom TTS Bar": "স্থির TTS বার টগল করুন"
|
||||
"Toggle Sticky Bottom TTS Bar": "স্থির TTS বার টগল করুন",
|
||||
"Display what I'm reading on Discord": "Discord-এ পড়ছি যা দেখান",
|
||||
"Show on Discord": "Discord-এ দেখান"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "ཕྱིར་འདོན།",
|
||||
"Set Timeout": "དུས་ཚོད་སྒྲིག་པ།",
|
||||
"Select Voice": "སྐད་གདངས་འདེམས།",
|
||||
"Toggle Sticky Bottom TTS Bar": "TTS སྡོམ་ཐིག་བརྗེ་བ།"
|
||||
"Toggle Sticky Bottom TTS Bar": "TTS སྡོམ་ཐིག་བརྗེ་བ།",
|
||||
"Display what I'm reading on Discord": "Discord ཐོག་ཀློག་བཞིན་པའི་དཔེ་ཆ་སྟོན།",
|
||||
"Show on Discord": "Discord ཐོག་སྟོན།"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "Exportieren",
|
||||
"Set Timeout": "Zeitlimit festlegen",
|
||||
"Select Voice": "Stimme auswählen",
|
||||
"Toggle Sticky Bottom TTS Bar": "Fixierte TTS-Leiste umschalten"
|
||||
"Toggle Sticky Bottom TTS Bar": "Fixierte TTS-Leiste umschalten",
|
||||
"Display what I'm reading on Discord": "Zeige was ich auf Discord lese",
|
||||
"Show on Discord": "Auf Discord zeigen"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "Εξαγωγή",
|
||||
"Set Timeout": "Ορισμός χρονικού ορίου",
|
||||
"Select Voice": "Επιλογή φωνής",
|
||||
"Toggle Sticky Bottom TTS Bar": "Εναλλαγή καρφιτσωμένης μπάρας TTS"
|
||||
"Toggle Sticky Bottom TTS Bar": "Εναλλαγή καρφιτσωμένης μπάρας TTS",
|
||||
"Display what I'm reading on Discord": "Εμφάνιση του βιβλίου που διαβάζω στο Discord",
|
||||
"Show on Discord": "Εμφάνιση στο Discord"
|
||||
}
|
||||
|
||||
@@ -898,5 +898,7 @@
|
||||
"Export": "Exportar",
|
||||
"Set Timeout": "Establecer tiempo límite",
|
||||
"Select Voice": "Seleccionar voz",
|
||||
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fija"
|
||||
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fija",
|
||||
"Display what I'm reading on Discord": "Mostrar lo que estoy leyendo en Discord",
|
||||
"Show on Discord": "Mostrar en Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "خروجی",
|
||||
"Set Timeout": "تنظیم مهلت زمانی",
|
||||
"Select Voice": "انتخاب صدا",
|
||||
"Toggle Sticky Bottom TTS Bar": "تغییر نوار TTS ثابت"
|
||||
"Toggle Sticky Bottom TTS Bar": "تغییر نوار TTS ثابت",
|
||||
"Display what I'm reading on Discord": "نمایش کتاب در حال خواندن در Discord",
|
||||
"Show on Discord": "نمایش در Discord"
|
||||
}
|
||||
|
||||
@@ -898,5 +898,7 @@
|
||||
"Export": "Exporter",
|
||||
"Set Timeout": "Définir le délai",
|
||||
"Select Voice": "Sélectionner la voix",
|
||||
"Toggle Sticky Bottom TTS Bar": "Basculer la barre TTS fixée"
|
||||
"Toggle Sticky Bottom TTS Bar": "Basculer la barre TTS fixée",
|
||||
"Display what I'm reading on Discord": "Afficher ce que je lis sur Discord",
|
||||
"Show on Discord": "Afficher sur Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "निर्यात करें",
|
||||
"Set Timeout": "टाइमआउट सेट करें",
|
||||
"Select Voice": "आवाज़ चुनें",
|
||||
"Toggle Sticky Bottom TTS Bar": "स्थिर TTS बार टॉगल करें"
|
||||
"Toggle Sticky Bottom TTS Bar": "स्थिर TTS बार टॉगल करें",
|
||||
"Display what I'm reading on Discord": "Discord पर पढ़ रही किताब दिखाएं",
|
||||
"Show on Discord": "Discord पर दिखाएं"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "Ekspor",
|
||||
"Set Timeout": "Atur batas waktu",
|
||||
"Select Voice": "Pilih suara",
|
||||
"Toggle Sticky Bottom TTS Bar": "Alihkan bilah TTS tetap"
|
||||
"Toggle Sticky Bottom TTS Bar": "Alihkan bilah TTS tetap",
|
||||
"Display what I'm reading on Discord": "Tampilkan buku yang sedang dibaca di Discord",
|
||||
"Show on Discord": "Tampilkan di Discord"
|
||||
}
|
||||
|
||||
@@ -898,5 +898,7 @@
|
||||
"Export": "Esporta",
|
||||
"Set Timeout": "Imposta timeout",
|
||||
"Select Voice": "Seleziona voce",
|
||||
"Toggle Sticky Bottom TTS Bar": "Attiva/disattiva barra TTS fissa"
|
||||
"Toggle Sticky Bottom TTS Bar": "Attiva/disattiva barra TTS fissa",
|
||||
"Display what I'm reading on Discord": "Mostra cosa sto leggendo su Discord",
|
||||
"Show on Discord": "Mostra su Discord"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "エクスポート",
|
||||
"Set Timeout": "タイムアウト設定",
|
||||
"Select Voice": "音声選択",
|
||||
"Toggle Sticky Bottom TTS Bar": "TTSバー固定切替"
|
||||
"Toggle Sticky Bottom TTS Bar": "TTSバー固定切替",
|
||||
"Display what I'm reading on Discord": "読書中の本をDiscordに表示",
|
||||
"Show on Discord": "Discordに表示"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "내보내기",
|
||||
"Set Timeout": "시간 제한 설정",
|
||||
"Select Voice": "음성 선택",
|
||||
"Toggle Sticky Bottom TTS Bar": "고정 TTS 바 전환"
|
||||
"Toggle Sticky Bottom TTS Bar": "고정 TTS 바 전환",
|
||||
"Display what I'm reading on Discord": "Discord에 읽는 책 표시",
|
||||
"Show on Discord": "Discord에 표시"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "Eksport",
|
||||
"Set Timeout": "Tetapkan masa tamat",
|
||||
"Select Voice": "Pilih suara",
|
||||
"Toggle Sticky Bottom TTS Bar": "Togol bar TTS melekit"
|
||||
"Toggle Sticky Bottom TTS Bar": "Togol bar TTS melekit",
|
||||
"Display what I'm reading on Discord": "Papar buku yang sedang dibaca di Discord",
|
||||
"Show on Discord": "Papar di Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "Exporteren",
|
||||
"Set Timeout": "Time-out instellen",
|
||||
"Select Voice": "Stem selecteren",
|
||||
"Toggle Sticky Bottom TTS Bar": "Vaste TTS-balk schakelen"
|
||||
"Toggle Sticky Bottom TTS Bar": "Vaste TTS-balk schakelen",
|
||||
"Display what I'm reading on Discord": "Toon wat ik lees op Discord",
|
||||
"Show on Discord": "Toon op Discord"
|
||||
}
|
||||
|
||||
@@ -908,5 +908,7 @@
|
||||
"Export": "Eksportuj",
|
||||
"Set Timeout": "Ustaw limit czasu",
|
||||
"Select Voice": "Wybierz głos",
|
||||
"Toggle Sticky Bottom TTS Bar": "Przełącz przypiętą dolną belkę TTS"
|
||||
"Toggle Sticky Bottom TTS Bar": "Przełącz przypiętą dolną belkę TTS",
|
||||
"Display what I'm reading on Discord": "Wyświetl to, co czytam na Discord",
|
||||
"Show on Discord": "Pokaż na Discord"
|
||||
}
|
||||
|
||||
@@ -898,5 +898,7 @@
|
||||
"Export": "Exportar",
|
||||
"Set Timeout": "Definir tempo limite",
|
||||
"Select Voice": "Selecionar voz",
|
||||
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada"
|
||||
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada",
|
||||
"Display what I'm reading on Discord": "Mostrar o que estou lendo no Discord",
|
||||
"Show on Discord": "Mostrar no Discord"
|
||||
}
|
||||
|
||||
@@ -908,5 +908,7 @@
|
||||
"Export": "Экспортировать",
|
||||
"Set Timeout": "Установить тайм-аут",
|
||||
"Select Voice": "Выбрать голос",
|
||||
"Toggle Sticky Bottom TTS Bar": "Переключить закреплённую панель TTS"
|
||||
"Toggle Sticky Bottom TTS Bar": "Переключить закреплённую панель TTS",
|
||||
"Display what I'm reading on Discord": "Показывать книгу в Discord",
|
||||
"Show on Discord": "Показать в Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "අපනයනය කරන්න",
|
||||
"Set Timeout": "කාල සීමාව සකසන්න",
|
||||
"Select Voice": "හඬ තෝරන්න",
|
||||
"Toggle Sticky Bottom TTS Bar": "ඇලවූ TTS තීරුව මාරු කරන්න"
|
||||
"Toggle Sticky Bottom TTS Bar": "ඇලවූ TTS තීරුව මාරු කරන්න",
|
||||
"Display what I'm reading on Discord": "Discord හි කියවන පොත පෙන්වන්න",
|
||||
"Show on Discord": "Discord හි පෙන්වන්න"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "Exportera",
|
||||
"Set Timeout": "Ställ in timeout",
|
||||
"Select Voice": "Välj röst",
|
||||
"Toggle Sticky Bottom TTS Bar": "Växla fast TTS-fält"
|
||||
"Toggle Sticky Bottom TTS Bar": "Växla fast TTS-fält",
|
||||
"Display what I'm reading on Discord": "Visa vad jag läser på Discord",
|
||||
"Show on Discord": "Visa på Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "ஏற்றுமதி",
|
||||
"Set Timeout": "நேர வரம்பை அமைக்கவும்",
|
||||
"Select Voice": "குரலைத் தேர்ந்தெடுக்கவும்",
|
||||
"Toggle Sticky Bottom TTS Bar": "நிலையான TTS பட்டியை மாற்றவும்"
|
||||
"Toggle Sticky Bottom TTS Bar": "நிலையான TTS பட்டியை மாற்றவும்",
|
||||
"Display what I'm reading on Discord": "Discord இல் படிக்கும் புத்தகத்தை காட்டு",
|
||||
"Show on Discord": "Discord இல் காட்டு"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "ส่งออก",
|
||||
"Set Timeout": "ตั้งค่าหมดเวลา",
|
||||
"Select Voice": "เลือกเสียง",
|
||||
"Toggle Sticky Bottom TTS Bar": "สลับแถบ TTS แบบติด"
|
||||
"Toggle Sticky Bottom TTS Bar": "สลับแถบ TTS แบบติด",
|
||||
"Display what I'm reading on Discord": "แสดงหนังสือที่กำลังอ่านบน Discord",
|
||||
"Show on Discord": "แสดงบน Discord"
|
||||
}
|
||||
|
||||
@@ -888,5 +888,7 @@
|
||||
"Export": "Dışa Aktar",
|
||||
"Set Timeout": "Zaman aşımını ayarla",
|
||||
"Select Voice": "Ses seç",
|
||||
"Toggle Sticky Bottom TTS Bar": "Sabit TTS çubuğunu değiştir"
|
||||
"Toggle Sticky Bottom TTS Bar": "Sabit TTS çubuğunu değiştir",
|
||||
"Display what I'm reading on Discord": "Discord'da okuduğumu göster",
|
||||
"Show on Discord": "Discord'da göster"
|
||||
}
|
||||
|
||||
@@ -908,5 +908,7 @@
|
||||
"Export": "Експортувати",
|
||||
"Set Timeout": "Встановити тайм-аут",
|
||||
"Select Voice": "Вибрати голос",
|
||||
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS"
|
||||
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS",
|
||||
"Display what I'm reading on Discord": "Показувати книгу в Discord",
|
||||
"Show on Discord": "Показати в Discord"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "Xuất",
|
||||
"Set Timeout": "Đặt thời gian chờ",
|
||||
"Select Voice": "Chọn giọng nói",
|
||||
"Toggle Sticky Bottom TTS Bar": "Bật/tắt thanh TTS cố định"
|
||||
"Toggle Sticky Bottom TTS Bar": "Bật/tắt thanh TTS cố định",
|
||||
"Display what I'm reading on Discord": "Hiển thị sách đang đọc trên Discord",
|
||||
"Show on Discord": "Hiện trên Discord"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "导出",
|
||||
"Set Timeout": "设置超时",
|
||||
"Select Voice": "选择语音",
|
||||
"Toggle Sticky Bottom TTS Bar": "切换固定TTS栏"
|
||||
"Toggle Sticky Bottom TTS Bar": "切换固定TTS栏",
|
||||
"Display what I'm reading on Discord": "在Discord显示阅读状态",
|
||||
"Show on Discord": "在Discord显示"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,7 @@
|
||||
"Export": "匯出",
|
||||
"Set Timeout": "設定逾時",
|
||||
"Select Voice": "選擇語音",
|
||||
"Toggle Sticky Bottom TTS Bar": "切換固定TTS欄"
|
||||
"Toggle Sticky Bottom TTS Bar": "切換固定TTS欄",
|
||||
"Display what I'm reading on Discord": "在Discord顯示閱讀狀態",
|
||||
"Show on Discord": "在Discord顯示"
|
||||
}
|
||||
|
||||
@@ -71,3 +71,4 @@ tauri-plugin-cli = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
discord-rich-presence = "1.0.0"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::State;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1462683110612144348";
|
||||
const MAX_TITLE_LENGTH: usize = 128;
|
||||
const MAX_AUTHOR_LENGTH: usize = 128;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DiscordRpcClient {
|
||||
client: Option<DiscordIpcClient>,
|
||||
current_book_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl DiscordRpcClient {
|
||||
pub fn new() -> Self {
|
||||
DiscordRpcClient {
|
||||
client: None,
|
||||
current_book_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_connected(&mut self) -> Result<(), String> {
|
||||
if self.client.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||
match client.connect() {
|
||||
Ok(_) => {
|
||||
log::info!("Successfully connected to Discord");
|
||||
self.client = Some(client);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to connect to Discord: {}", e);
|
||||
Err(format!("Failed to connect to Discord: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) {
|
||||
if let Some(mut client) = self.client.take() {
|
||||
let _ = client.close();
|
||||
log::debug!("Disconnected from Discord");
|
||||
}
|
||||
self.current_book_hash = None;
|
||||
}
|
||||
|
||||
fn truncate_string(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len - 3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BookPresenceData {
|
||||
book_hash: String,
|
||||
title: String,
|
||||
author: Option<String>,
|
||||
cover_url: Option<String>,
|
||||
session_start: i64,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
#[tauri::command]
|
||||
pub async fn update_book_presence(
|
||||
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
|
||||
presence: BookPresenceData,
|
||||
) -> Result<(), String> {
|
||||
let mut client = state
|
||||
.lock()
|
||||
.map_err(|e| format!("Mutex lock error: {}", e))?;
|
||||
|
||||
if let Err(e) = client.ensure_connected() {
|
||||
log::debug!("Discord not available: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let BookPresenceData {
|
||||
book_hash,
|
||||
title,
|
||||
author,
|
||||
cover_url,
|
||||
session_start,
|
||||
} = presence;
|
||||
|
||||
// Truncate title and author to avoid Discord API limits
|
||||
let truncated_title = DiscordRpcClient::truncate_string(&title, MAX_TITLE_LENGTH);
|
||||
let state_text = if let Some(ref author_name) = author {
|
||||
let truncated_author = DiscordRpcClient::truncate_string(author_name, MAX_AUTHOR_LENGTH);
|
||||
format!("by {}", truncated_author)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut activity_builder = activity::Activity::new().details(&truncated_title);
|
||||
|
||||
if !state_text.is_empty() {
|
||||
activity_builder = activity_builder.state(&state_text);
|
||||
}
|
||||
|
||||
activity_builder =
|
||||
activity_builder.timestamps(activity::Timestamps::new().start(session_start / 1000));
|
||||
|
||||
let large_image = cover_url
|
||||
.as_deref()
|
||||
.filter(|url| url.starts_with("https://"))
|
||||
.unwrap_or("book_icon");
|
||||
let assets_builder = activity::Assets::new()
|
||||
.large_image(large_image)
|
||||
.large_text(&truncated_title);
|
||||
|
||||
activity_builder = activity_builder.assets(assets_builder);
|
||||
|
||||
let button = activity::Button::new("Read on Readest", "https://web.readest.com");
|
||||
activity_builder = activity_builder.buttons(vec![button]);
|
||||
|
||||
if let Some(ref mut discord_client) = client.client {
|
||||
match discord_client.set_activity(activity_builder) {
|
||||
Ok(_) => {
|
||||
log::info!("Successfully updated Discord presence");
|
||||
client.current_book_hash = Some(book_hash);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update Discord activity: {}", e);
|
||||
client.disconnect();
|
||||
Err(format!("Failed to update Discord activity: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err("Discord client not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
#[tauri::command]
|
||||
pub async fn clear_book_presence(
|
||||
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
|
||||
) -> Result<(), String> {
|
||||
let mut client = state
|
||||
.lock()
|
||||
.map_err(|e| format!("Mutex lock error: {}", e))?;
|
||||
|
||||
if let Some(ref mut discord_client) = client.client {
|
||||
match discord_client.clear_activity() {
|
||||
Ok(_) => {
|
||||
log::info!("Successfully cleared Discord presence");
|
||||
client.current_book_hash = None;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to clear Discord activity: {}", e);
|
||||
client.disconnect();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!("No Discord client to clear");
|
||||
client.current_book_hash = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
#[tauri::command]
|
||||
pub async fn update_book_presence(_presence: BookPresenceData) -> Result<(), String> {
|
||||
Ok(()) // No-op on non-desktop platforms
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
#[tauri::command]
|
||||
pub async fn clear_book_presence() -> Result<(), String> {
|
||||
Ok(()) // No-op on non-desktop platforms
|
||||
}
|
||||
@@ -22,6 +22,8 @@ use tauri_plugin_fs::FsExt;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::{Listener, Url};
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
mod discord_rpc;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod transfer_file;
|
||||
@@ -41,14 +43,14 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
let asset_protocol_scope = app.asset_protocol_scope();
|
||||
for file in &files {
|
||||
if let Err(e) = fs_scope.allow_file(file) {
|
||||
eprintln!("Failed to allow file in fs_scope: {e}");
|
||||
log::error!("Failed to allow file in fs_scope: {e}");
|
||||
} else {
|
||||
println!("Allowed file in fs_scope: {file:?}");
|
||||
log::debug!("Allowed file in fs_scope: {file:?}");
|
||||
}
|
||||
if let Err(e) = asset_protocol_scope.allow_file(file) {
|
||||
eprintln!("Failed to allow file in asset_protocol_scope: {e}");
|
||||
log::error!("Failed to allow file in asset_protocol_scope: {e}");
|
||||
} else {
|
||||
println!("Allowed file in asset_protocol_scope: {file:?}");
|
||||
log::debug!("Allowed file in asset_protocol_scope: {file:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,14 +59,14 @@ fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
|
||||
let fs_scope = app.fs_scope();
|
||||
let asset_protocol_scope = app.asset_protocol_scope();
|
||||
if let Err(e) = fs_scope.allow_directory(dir, true) {
|
||||
eprintln!("Failed to allow directory in fs_scope: {e}");
|
||||
log::error!("Failed to allow directory in fs_scope: {e}");
|
||||
} else {
|
||||
println!("Allowed directory in fs_scope: {dir:?}");
|
||||
log::info!("Allowed directory in fs_scope: {dir:?}");
|
||||
}
|
||||
if let Err(e) = asset_protocol_scope.allow_directory(dir, true) {
|
||||
eprintln!("Failed to allow directory in asset_protocol_scope: {e}");
|
||||
log::error!("Failed to allow directory in asset_protocol_scope: {e}");
|
||||
} else {
|
||||
println!("Allowed directory in asset_protocol_scope: {dir:?}");
|
||||
log::info!("Allowed directory in asset_protocol_scope: {dir:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +149,11 @@ struct SingleInstancePayload {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_oauth::init())
|
||||
@@ -162,6 +169,10 @@ pub fn run() {
|
||||
macos::apple_auth::start_apple_sign_in,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::traffic_light::set_traffic_lights,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
discord_rpc::update_book_presence,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
discord_rpc::clear_book_presence,
|
||||
])
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_persisted_scope::init())
|
||||
@@ -210,6 +221,13 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.setup(|#[allow(unused_variables)] app| {
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
use std::sync::{Arc, Mutex};
|
||||
let discord_client = Arc::new(Mutex::new(discord_rpc::DiscordRpcClient::new()));
|
||||
app.manage(discord_client);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let files = get_files_from_argv(std::env::args().collect());
|
||||
@@ -266,14 +284,6 @@ pub fn run() {
|
||||
let _ = app.deep_link().register_all();
|
||||
}
|
||||
|
||||
if let Err(e) = app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
) {
|
||||
eprintln!("Failed to initialize tauri_plugin_log: {e}");
|
||||
};
|
||||
|
||||
// Check for e-ink device on Android before building the window
|
||||
#[cfg(target_os = "android")]
|
||||
let is_eink = android::is_eink_device();
|
||||
|
||||
@@ -38,8 +38,8 @@ pub struct AppleIDAuthorizationResponse {
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = RefCell::new(None);
|
||||
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = RefCell::new(None);
|
||||
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = const { RefCell::new(None) };
|
||||
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -70,7 +70,7 @@ define_class!(
|
||||
{
|
||||
let user_identifier = {
|
||||
let user = credential.user();
|
||||
if user.len() > 0 {
|
||||
if !user.is_empty() {
|
||||
Some(user.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -183,7 +183,7 @@ pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest)
|
||||
let auth_request = &request as &ASAuthorizationRequest;
|
||||
let controller = ASAuthorizationController::initWithAuthorizationRequests(
|
||||
ASAuthorizationController::alloc(),
|
||||
&*NSArray::from_slice(&[auth_request]),
|
||||
&NSArray::from_slice(&[auth_request]),
|
||||
);
|
||||
|
||||
let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone());
|
||||
|
||||
@@ -35,16 +35,11 @@ impl ThreadSafeObjcPointer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AuthSession {
|
||||
auth_session: Option<ThreadSafeObjcPointer>,
|
||||
}
|
||||
|
||||
impl Default for AuthSession {
|
||||
fn default() -> Self {
|
||||
Self { auth_session: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthSession {
|
||||
fn drop(&mut self) {
|
||||
if let Some(session) = &self.auth_session {
|
||||
|
||||
@@ -67,9 +67,14 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode) {
|
||||
return render(<EnvProvider>{ui}</EnvProvider>);
|
||||
return render(
|
||||
<EnvProvider>
|
||||
<AuthProvider>{ui}</AuthProvider>
|
||||
</EnvProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ProofreadRulesManager', () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
|
||||
import { useDiscordPresence } from '@/hooks/useDiscordPresence';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import { removeTabIndex } from '@/utils/a11y';
|
||||
@@ -85,6 +86,7 @@ const FoliateViewer: React.FC<{
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { applyBackgroundTexture } = useBackgroundTexture();
|
||||
const { applyEinkMode } = useEinkMode();
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewState = getViewState(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
@@ -98,6 +100,12 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
useAutoFocus<HTMLDivElement>({ ref: containerRef });
|
||||
|
||||
useDiscordPresence(
|
||||
bookData?.book || null,
|
||||
!!viewState?.isPrimary,
|
||||
settings.discordRichPresenceEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { uniqueId } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { clearDiscordPresence } from '@/utils/discord';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
|
||||
@@ -136,6 +137,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
const saveConfigAndCloseBook = async (bookKey: string) => {
|
||||
console.log('Closing book', bookKey);
|
||||
|
||||
const viewState = getViewState(bookKey);
|
||||
if (viewState?.isPrimary && appService?.isDesktopApp) {
|
||||
await clearDiscordPresence(appService);
|
||||
}
|
||||
|
||||
try {
|
||||
getView(bookKey)?.close();
|
||||
getView(bookKey)?.remove();
|
||||
|
||||
@@ -3,7 +3,9 @@ import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
@@ -14,6 +16,8 @@ import { isWebAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
|
||||
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
@@ -28,7 +32,9 @@ interface BookMenuProps {
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { settings } = useSettingsStore();
|
||||
const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
@@ -93,6 +99,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const toggleDiscordPresence = () => {
|
||||
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
|
||||
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
|
||||
setIsDropdownOpen?.(false);
|
||||
if (discordRichPresenceEnabled && !user) {
|
||||
navigateToLogin(router);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -153,6 +167,17 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
|
||||
</>
|
||||
)}
|
||||
{appService?.isDesktopApp && (
|
||||
<>
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem
|
||||
label={_('Show on Discord')}
|
||||
tooltip={_("Display what I'm reading on Discord")}
|
||||
toggled={settings.discordRichPresenceEnabled}
|
||||
onClick={toggleDiscordPresence}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { updateDiscordPresence, clearDiscordPresence } from '@/utils/discord';
|
||||
|
||||
/**
|
||||
* Hook to manage Discord Rich Presence for a book
|
||||
* @param book - Current book being read (null if no book)
|
||||
* @param isPrimary - Whether this is the primary book (for multi-book scenarios)
|
||||
*/
|
||||
export const useDiscordPresence = (book: Book | null, isPrimary: boolean, enabled: boolean) => {
|
||||
const { appService } = useEnv();
|
||||
|
||||
const sessionStartRef = useRef<number>(Date.now());
|
||||
const updateIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentBookHashRef = useRef<string | null>(null);
|
||||
const isUpdatingRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrimary || !book) return;
|
||||
if (!appService?.isDesktopApp) return;
|
||||
|
||||
const stopUpdates = () => {
|
||||
if (updateIntervalRef.current) {
|
||||
clearInterval(updateIntervalRef.current);
|
||||
updateIntervalRef.current = null;
|
||||
}
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
stopUpdates();
|
||||
clearDiscordPresence(appService);
|
||||
currentBookHashRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBookHashRef.current !== book.hash) {
|
||||
sessionStartRef.current = Date.now();
|
||||
currentBookHashRef.current = book.hash;
|
||||
}
|
||||
|
||||
const safeUpdate = async () => {
|
||||
if (isUpdatingRef.current) return;
|
||||
|
||||
isUpdatingRef.current = true;
|
||||
try {
|
||||
await updateDiscordPresence(book, sessionStartRef.current, appService);
|
||||
} catch (err) {
|
||||
console.error('Discord presence update failed:', err);
|
||||
} finally {
|
||||
isUpdatingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void safeUpdate();
|
||||
|
||||
updateIntervalRef.current = setInterval(() => {
|
||||
void safeUpdate();
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
stopUpdates();
|
||||
clearDiscordPresence(appService);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [book?.hash, isPrimary, enabled, appService]);
|
||||
};
|
||||
@@ -44,6 +44,7 @@ export const uploadFile = async (
|
||||
fileFullPath: string,
|
||||
onProgress?: ProgressHandler,
|
||||
bookHash?: string,
|
||||
temp = false,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
|
||||
@@ -55,15 +56,18 @@ export const uploadFile = async (
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
bookHash,
|
||||
temp,
|
||||
}),
|
||||
});
|
||||
|
||||
const { uploadUrl } = await response.json();
|
||||
const { uploadUrl, downloadUrl }: { uploadUrl: string; downloadUrl?: string } =
|
||||
await response.json();
|
||||
if (isWebAppPlatform()) {
|
||||
await webUpload(file, uploadUrl, onProgress);
|
||||
} else {
|
||||
await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress);
|
||||
}
|
||||
return temp ? downloadUrl : undefined;
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
validateUserAndToken,
|
||||
STORAGE_QUOTA_GRACE_BYTES,
|
||||
} from '@/utils/access';
|
||||
import { getUploadSignedUrl } from '@/utils/object';
|
||||
import { getDownloadSignedUrl, getUploadSignedUrl } from '@/utils/object';
|
||||
import { READEST_PUBLIC_STORAGE_BASE_URL } from '@/services/constants';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
@@ -15,13 +16,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
const { user, token } = await validateUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileName, fileSize, bookHash } = req.body;
|
||||
const { fileName, fileSize, bookHash, temp = false } = req.body;
|
||||
if (temp) {
|
||||
try {
|
||||
const datetime = new Date();
|
||||
const timeStr = datetime.toISOString().replace(/[-:]/g, '').replace('T', '').slice(0, 10);
|
||||
const userStr = user.id.slice(0, 8);
|
||||
const fileKey = `temp/img/${timeStr}/${userStr}/${fileName}`;
|
||||
const bucketName = process.env['TEMP_STORAGE_PUBLIC_BUCKET_NAME'] || '';
|
||||
const uploadUrl = await getUploadSignedUrl(fileKey, fileSize, 1800, bucketName);
|
||||
const downloadUrl = await getDownloadSignedUrl(fileKey, 3 * 86400, bucketName);
|
||||
const pathname = new URL(downloadUrl).pathname;
|
||||
const publicBaseUrl = READEST_PUBLIC_STORAGE_BASE_URL;
|
||||
const publicDownloadUrl = `${publicBaseUrl}${pathname.replace(`/${bucketName}`, '')}`;
|
||||
return res.status(200).json({
|
||||
uploadUrl,
|
||||
downloadUrl: publicDownloadUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating presigned post for temp file:', error);
|
||||
return res.status(500).json({ error: 'Could not create presigned post' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fileName || !fileSize) {
|
||||
return res.status(400).json({ error: 'Missing file info' });
|
||||
}
|
||||
|
||||
@@ -514,15 +514,23 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFileToCloud(lfp: string, cfp: string, handleProgress: ProgressHandler, hash: string) {
|
||||
async uploadFileToCloud(
|
||||
lfp: string,
|
||||
cfp: string,
|
||||
base: BaseDir,
|
||||
handleProgress: ProgressHandler,
|
||||
hash: string,
|
||||
temp: boolean = false,
|
||||
) {
|
||||
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);
|
||||
const file = await this.fs.openFile(lfp, base, cfp);
|
||||
const localFullpath = await this.resolveFilePath(lfp, base);
|
||||
const downloadUrl = await uploadFile(file, localFullpath, handleProgress, hash, temp);
|
||||
const f = file as ClosableFile;
|
||||
if (f && f.close) {
|
||||
await f.close();
|
||||
}
|
||||
return downloadUrl;
|
||||
}
|
||||
|
||||
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
|
||||
@@ -549,7 +557,7 @@ export abstract class BaseAppService implements AppService {
|
||||
if (coverExist) {
|
||||
const lfp = getCoverFilename(book);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`;
|
||||
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
|
||||
await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash);
|
||||
uploaded = true;
|
||||
completedFiles.count++;
|
||||
}
|
||||
@@ -557,7 +565,7 @@ export abstract class BaseAppService implements AppService {
|
||||
if (bookFileExist) {
|
||||
const lfp = getLocalBookFilename(book);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
|
||||
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
|
||||
await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash);
|
||||
uploaded = true;
|
||||
completedFiles.count++;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
lastOpenBooks: [],
|
||||
autoImportBooksOnOpen: false,
|
||||
telemetryEnabled: true,
|
||||
discordRichPresenceEnabled: false,
|
||||
libraryViewMode: 'grid',
|
||||
librarySortBy: 'updated',
|
||||
librarySortAscending: false,
|
||||
@@ -647,6 +648,8 @@ export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
|
||||
|
||||
export const READEST_CHANGELOG_FILE = `${LATEST_DOWNLOAD_BASE_URL}/release-notes.json`;
|
||||
|
||||
export const READEST_PUBLIC_STORAGE_BASE_URL = 'https://storage.readest.com';
|
||||
|
||||
export const READEST_OPDS_USER_AGENT = 'Readest/1.0 (OPDS Browser)';
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
|
||||
|
||||
@@ -6,7 +6,14 @@ import { OPDSCatalog } from './opds';
|
||||
|
||||
export type ThemeType = 'light' | 'dark' | 'auto';
|
||||
export type LibraryViewModeType = 'grid' | 'list';
|
||||
export type LibrarySortByType = 'title' | 'author' | 'updated' | 'created' | 'size' | 'format' | 'published';
|
||||
export type LibrarySortByType =
|
||||
| 'title'
|
||||
| 'author'
|
||||
| 'updated'
|
||||
| 'created'
|
||||
| 'size'
|
||||
| 'format'
|
||||
| 'published';
|
||||
export type LibraryCoverFitType = 'crop' | 'fit';
|
||||
|
||||
export type KOSyncChecksumMethod = 'binary' | 'filename';
|
||||
@@ -60,6 +67,7 @@ export interface SystemSettings {
|
||||
savedBookCoverForLockScreen: string;
|
||||
savedBookCoverForLockScreenPath: string;
|
||||
telemetryEnabled: boolean;
|
||||
discordRichPresenceEnabled: boolean;
|
||||
libraryViewMode: LibraryViewModeType;
|
||||
librarySortBy: LibrarySortByType;
|
||||
librarySortAscending: boolean;
|
||||
|
||||
@@ -132,6 +132,14 @@ export interface AppService {
|
||||
redownload?: boolean,
|
||||
onProgress?: ProgressHandler,
|
||||
): Promise<void>;
|
||||
uploadFileToCloud(
|
||||
lfp: string,
|
||||
cfp: string,
|
||||
base: BaseDir,
|
||||
handleProgress: ProgressHandler,
|
||||
hash: string,
|
||||
temp?: boolean,
|
||||
): Promise<string | undefined>;
|
||||
downloadBookCovers(books: Book[], redownload?: boolean): Promise<void>;
|
||||
exportBook(book: Book): Promise<boolean>;
|
||||
isBookAvailable(book: Book): Promise<boolean>;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
import { getCoverFilename } from './book';
|
||||
import { processDiscordCover } from './image';
|
||||
|
||||
type CacheEntry = {
|
||||
url: string | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const coverUrlCache = new Map<string, CacheEntry>();
|
||||
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
type BookPresence = {
|
||||
bookHash: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
coverUrl: string | null;
|
||||
sessionStart: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an HTTPS cover URL suitable for Discord Rich Presence
|
||||
* - Caches successful uploads for session
|
||||
* - Caches failures (undefined) for 1 hour to avoid retries
|
||||
* - Processes cover with Readest icon overlay
|
||||
*/
|
||||
const getCoverUrlForDiscord = async (
|
||||
book: Book,
|
||||
appService: AppService,
|
||||
): Promise<string | undefined> => {
|
||||
const cached = coverUrlCache.get(book.hash);
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.timestamp > CACHE_DURATION;
|
||||
if (!isExpired) {
|
||||
return cached.url ?? undefined;
|
||||
}
|
||||
if (!cached.url) {
|
||||
coverUrlCache.delete(book.hash);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = getCoverFilename(book);
|
||||
|
||||
const exists = await appService.exists(fp, 'Books');
|
||||
if (!exists) {
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cacheKey = `drp_${book.hash}.jpg`;
|
||||
// Check if processed image exists in cache
|
||||
const cachedExists = await appService.exists(cacheKey, 'Cache');
|
||||
if (cachedExists) {
|
||||
const downloadUrl = await appService.uploadFileToCloud(
|
||||
cacheKey,
|
||||
cacheKey,
|
||||
'Cache',
|
||||
() => {},
|
||||
book.hash,
|
||||
true,
|
||||
);
|
||||
|
||||
if (downloadUrl) {
|
||||
coverUrlCache.set(book.hash, { url: downloadUrl, timestamp: Date.now() });
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = await appService.resolveFilePath(fp, 'Books');
|
||||
const coverUrl = await appService.getImageURL(fullPath);
|
||||
const iconUrl = '/icon-tiny.png';
|
||||
|
||||
const processedBlob = await processDiscordCover(coverUrl, iconUrl);
|
||||
console.log('Processed Discord cover for book:', book.title);
|
||||
const arrayBuffer = await processedBlob.arrayBuffer();
|
||||
await appService.writeFile(cacheKey, 'Cache', arrayBuffer);
|
||||
const downloadUrl = await appService.uploadFileToCloud(
|
||||
cacheKey,
|
||||
cacheKey,
|
||||
'Cache',
|
||||
() => {},
|
||||
book.hash,
|
||||
true,
|
||||
);
|
||||
|
||||
if (downloadUrl) {
|
||||
coverUrlCache.set(book.hash, { url: downloadUrl, timestamp: Date.now() });
|
||||
return downloadUrl;
|
||||
}
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.warn('Failed to process/upload cover for Discord:', error);
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Discord Rich Presence with current book information
|
||||
*/
|
||||
export const updateDiscordPresence = async (
|
||||
book: Book,
|
||||
sessionStart: number,
|
||||
appService: AppService,
|
||||
): Promise<void> => {
|
||||
if (!appService?.isDesktopApp) return;
|
||||
|
||||
try {
|
||||
const coverUrl = await getCoverUrlForDiscord(book, appService);
|
||||
const bookPresence: BookPresence = {
|
||||
bookHash: book.hash,
|
||||
title: book.title,
|
||||
author: book.author || null,
|
||||
coverUrl: coverUrl || null,
|
||||
sessionStart,
|
||||
};
|
||||
|
||||
await invoke('update_book_presence', { presence: bookPresence });
|
||||
} catch (error) {
|
||||
console.warn('Failed to update Discord presence:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear Discord Rich Presence
|
||||
*/
|
||||
export const clearDiscordPresence = async (appService: AppService): Promise<void> => {
|
||||
if (!appService?.isDesktopApp) return;
|
||||
|
||||
try {
|
||||
await invoke('clear_book_presence');
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear Discord presence:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,131 @@
|
||||
/**
|
||||
* Process book cover for Discord Rich Presence:
|
||||
* - Fit to 512x512 with transparent background
|
||||
* - Add Readest icon overlay at bottom right (10px padding)
|
||||
* - Return as JPEG blob
|
||||
*/
|
||||
export async function processDiscordCover(coverUrl: string, iconUrl: string): Promise<Blob> {
|
||||
const SIZE = 512;
|
||||
const ICON_WIDTH = 224;
|
||||
const ICON_HEIGHT = 182;
|
||||
const PADDING = 10;
|
||||
|
||||
try {
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const coverBlob = await coverResponse.blob();
|
||||
const coverImg = new Image();
|
||||
coverImg.crossOrigin = 'anonymous';
|
||||
|
||||
const iconResponse = await fetch(iconUrl);
|
||||
const iconBlob = await iconResponse.blob();
|
||||
const iconImg = new Image();
|
||||
iconImg.crossOrigin = 'anonymous';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let coverLoaded = false;
|
||||
let iconLoaded = false;
|
||||
|
||||
const checkBothLoaded = () => {
|
||||
if (!coverLoaded || !iconLoaded) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = SIZE;
|
||||
canvas.height = SIZE;
|
||||
|
||||
// Calculate cover dimensions to fit in 512x512
|
||||
const aspectRatio = coverImg.width / coverImg.height;
|
||||
let drawWidth, drawHeight, offsetX, offsetY;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Wider than tall
|
||||
drawWidth = SIZE;
|
||||
drawHeight = SIZE / aspectRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (SIZE - drawHeight) / 2;
|
||||
} else {
|
||||
// Taller than wide
|
||||
drawHeight = SIZE;
|
||||
drawWidth = SIZE * aspectRatio;
|
||||
offsetX = (SIZE - drawWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
// Draw cover image centered
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(coverImg, offsetX, offsetY, drawWidth, drawHeight);
|
||||
|
||||
// Draw icon at bottom right
|
||||
ctx.drawImage(
|
||||
iconImg,
|
||||
SIZE - ICON_WIDTH - PADDING,
|
||||
SIZE - ICON_HEIGHT - PADDING,
|
||||
ICON_WIDTH,
|
||||
ICON_HEIGHT,
|
||||
);
|
||||
|
||||
// Convert to JPEG blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9,
|
||||
);
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to process cover: ${error}`));
|
||||
}
|
||||
};
|
||||
|
||||
coverImg.onload = () => {
|
||||
coverLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
iconImg.onload = () => {
|
||||
iconLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
coverImg.onerror = () => reject(new Error('Failed to load cover image'));
|
||||
iconImg.onerror = () => reject(new Error('Failed to load icon image'));
|
||||
|
||||
const coverObjectUrl = URL.createObjectURL(coverBlob);
|
||||
const iconObjectUrl = URL.createObjectURL(iconBlob);
|
||||
|
||||
coverImg.src = coverObjectUrl;
|
||||
iconImg.src = iconObjectUrl;
|
||||
|
||||
coverImg.onload = function () {
|
||||
URL.revokeObjectURL(coverObjectUrl);
|
||||
coverLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
iconImg.onload = function () {
|
||||
URL.revokeObjectURL(iconObjectUrl);
|
||||
iconLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing Discord cover:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageAsBase64(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
@@ -2,13 +2,17 @@ import { s3Storage } from './s3';
|
||||
import { r2Storage } from './r2';
|
||||
import { getStorageType } from './storage';
|
||||
|
||||
export const getDownloadSignedUrl = async (fileKey: string, expiresIn: number) => {
|
||||
export const getDownloadSignedUrl = async (
|
||||
fileKey: string,
|
||||
expiresIn: number,
|
||||
bucketName?: string,
|
||||
) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
|
||||
}
|
||||
};
|
||||
@@ -17,24 +21,25 @@ export const getUploadSignedUrl = async (
|
||||
fileKey: string,
|
||||
contentLength: number,
|
||||
expiresIn: number,
|
||||
bucketName?: string,
|
||||
) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteObject = async (fileKey: string) => {
|
||||
export const deleteObject = async (fileKey: string, bucketName?: string) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.deleteObject(bucketName, fileKey);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.deleteObject(bucketName, fileKey);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user