feat(discord): support show reading status with Discord Rich Presence, closes #1538 (#2998)

This commit is contained in:
Huang Xin
2026-01-19 17:42:19 +01:00
committed by GitHub
parent 1704736bc8
commit d9a6cffe78
50 changed files with 794 additions and 85 deletions
Generated
+32 -7
View File
@@ -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",
+2
View File
@@ -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
+1
View File
@@ -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顯示"
}
+1
View File
@@ -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
}
+26 -16
View File
@@ -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]);
};
+5 -1
View File
@@ -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' });
}
+14 -6
View File
@@ -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;
+9 -1
View File
@@ -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;
+8
View File
@@ -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>;
+139
View File
@@ -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);
}
};
+128
View File
@@ -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: {
+13 -8
View File
@@ -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);
}
};