diff --git a/Cargo.lock b/Cargo.lock index a5102ab9..f1cb86b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/apps/readest-app/.env.local.example b/apps/readest-app/.env.local.example index e009a3c7..95249567 100644 --- a/apps/readest-app/.env.local.example +++ b/apps/readest-app/.env.local.example @@ -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 diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index ebf51123..5ae94392 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/public/icon-tiny.png b/apps/readest-app/public/icon-tiny.png new file mode 100644 index 00000000..2b7f213d Binary files /dev/null and b/apps/readest-app/public/icon-tiny.png differ diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 2bf84261..cb7f6d50 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 347f69b0..19779742 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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-এ দেখান" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index de39ec79..2bb68a13 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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 ཐོག་སྟོན།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 53e3feb7..31c623f3 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 2fc63af8..f7c31d9c 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 3324fd55..442c3791 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 7d0d19e1..ca57da0c 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index a8bbf858..00cbf800 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index d168dc55..35a627ea 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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 पर दिखाएं" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index becc6c68..4669fc80 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 75d2f74c..6d01e75b 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d06fb161..73066427 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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に表示" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index a7351113..3118817c 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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에 표시" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index af9f1da7..453b390b 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index f93a0b6a..3ec687fb 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 87eb534b..4bf1e97e 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 79af676e..b51f9f55 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 574974fb..13196f03 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 5541b65c..d0752aae 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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 හි පෙන්වන්න" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 327b0d40..ac7d63bd 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index cbc56bac..b01f23f0 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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 இல் காட்டு" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index e49f0e11..da82adff 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index f4d53beb..24fb0250 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 59b1e82d..3631ef3b 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 1f0e8d25..c7ad1ca9 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 36372cb2..63f5650c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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显示" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index f271ab6e..f0c78599 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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顯示" } diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 728731de..e9adebc1 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -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" diff --git a/apps/readest-app/src-tauri/src/discord_rpc.rs b/apps/readest-app/src-tauri/src/discord_rpc.rs new file mode 100644 index 00000000..e712756e --- /dev/null +++ b/apps/readest-app/src-tauri/src/discord_rpc.rs @@ -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, + current_book_hash: Option, +} + +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, + cover_url: Option, + 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>>, + 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>>, +) -> 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 +} diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index c7ec9b85..a2dd53c0 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -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) { 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(); diff --git a/apps/readest-app/src-tauri/src/macos/apple_auth.rs b/apps/readest-app/src-tauri/src/macos/apple_auth.rs index 127cefbe..32ba8494 100644 --- a/apps/readest-app/src-tauri/src/macos/apple_auth.rs +++ b/apps/readest-app/src-tauri/src/macos/apple_auth.rs @@ -38,8 +38,8 @@ pub struct AppleIDAuthorizationResponse { } thread_local! { - static APPLE_SIGN_IN_DELEGATE: RefCell>> = RefCell::new(None); - static AUTHORIZATION_CONTROLLER: RefCell>> = RefCell::new(None); + static APPLE_SIGN_IN_DELEGATE: RefCell>> = const { RefCell::new(None) }; + static AUTHORIZATION_CONTROLLER: RefCell>> = 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()); diff --git a/apps/readest-app/src-tauri/src/macos/safari_auth.rs b/apps/readest-app/src-tauri/src/macos/safari_auth.rs index ab36108e..f4c4607c 100644 --- a/apps/readest-app/src-tauri/src/macos/safari_auth.rs +++ b/apps/readest-app/src-tauri/src/macos/safari_auth.rs @@ -35,16 +35,11 @@ impl ThreadSafeObjcPointer { } } +#[derive(Default)] pub struct AuthSession { auth_session: Option, } -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 { diff --git a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx index d1b64ab8..53c52a5e 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx @@ -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({ui}); + return render( + + {ui} + , + ); } describe('ProofreadRulesManager', () => { diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 253afaef..07b236fa 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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({ ref: containerRef }); + useDiscordPresence( + bookData?.book || null, + !!viewState?.isPrimary, + settings.discordRichPresenceEnabled, + ); + useEffect(() => { const timer = setTimeout(() => setToastMessage(''), 2000); return () => clearTimeout(timer); diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 7dbb55e8..e9aaac82 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -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(); diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index c844a673..3ec54dff 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -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 = ({ 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 = ({ 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 ( = ({ menuClassName, setIsDropdownOpen }) )} + {appService?.isDesktopApp && ( + <> +
+ + + )}

diff --git a/apps/readest-app/src/hooks/useDiscordPresence.ts b/apps/readest-app/src/hooks/useDiscordPresence.ts new file mode 100644 index 00000000..3b5fd437 --- /dev/null +++ b/apps/readest-app/src/hooks/useDiscordPresence.ts @@ -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(Date.now()); + const updateIntervalRef = useRef(null); + const currentBookHashRef = useRef(null); + const isUpdatingRef = useRef(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]); +}; diff --git a/apps/readest-app/src/libs/storage.ts b/apps/readest-app/src/libs/storage.ts index 5a43ad69..68a77335 100644 --- a/apps/readest-app/src/libs/storage.ts +++ b/apps/readest-app/src/libs/storage.ts @@ -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) { diff --git a/apps/readest-app/src/pages/api/storage/upload.ts b/apps/readest-app/src/pages/api/storage/upload.ts index 3ef1b15c..2c55dc93 100644 --- a/apps/readest-app/src/pages/api/storage/upload.ts +++ b/apps/readest-app/src/pages/api/storage/upload.ts @@ -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' }); } diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index bcf51a19..746ec645 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 { @@ -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++; } diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 073b7ed7..1ab98f41 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -70,6 +70,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { 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; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index f8c177d3..ab463947 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 3ecf3c0c..3edfbc35 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -132,6 +132,14 @@ export interface AppService { redownload?: boolean, onProgress?: ProgressHandler, ): Promise; + uploadFileToCloud( + lfp: string, + cfp: string, + base: BaseDir, + handleProgress: ProgressHandler, + hash: string, + temp?: boolean, + ): Promise; downloadBookCovers(books: Book[], redownload?: boolean): Promise; exportBook(book: Book): Promise; isBookAvailable(book: Book): Promise; diff --git a/apps/readest-app/src/utils/discord.ts b/apps/readest-app/src/utils/discord.ts new file mode 100644 index 00000000..800f84b2 --- /dev/null +++ b/apps/readest-app/src/utils/discord.ts @@ -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(); +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 => { + 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 => { + 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 => { + if (!appService?.isDesktopApp) return; + + try { + await invoke('clear_book_presence'); + } catch (error) { + console.warn('Failed to clear Discord presence:', error); + } +}; diff --git a/apps/readest-app/src/utils/image.ts b/apps/readest-app/src/utils/image.ts index 7cf60004..5ac77d70 100644 --- a/apps/readest-app/src/utils/image.ts +++ b/apps/readest-app/src/utils/image.ts @@ -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 { + 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: { diff --git a/apps/readest-app/src/utils/object.ts b/apps/readest-app/src/utils/object.ts index a94ff9a5..8911b95a 100644 --- a/apps/readest-app/src/utils/object.ts +++ b/apps/readest-app/src/utils/object.ts @@ -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); } };