From d9a6cffe7880973fd76b469e8173c8ae68fe6a9e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 19 Jan 2026 17:42:19 +0100 Subject: [PATCH] feat(discord): support show reading status with Discord Rich Presence, closes #1538 (#2998) --- Cargo.lock | 39 +++- apps/readest-app/.env.local.example | 2 + apps/readest-app/package.json | 1 + apps/readest-app/public/icon-tiny.png | Bin 0 -> 52719 bytes .../public/locales/ar/translation.json | 4 +- .../public/locales/bn/translation.json | 4 +- .../public/locales/bo/translation.json | 4 +- .../public/locales/de/translation.json | 4 +- .../public/locales/el/translation.json | 4 +- .../public/locales/es/translation.json | 4 +- .../public/locales/fa/translation.json | 4 +- .../public/locales/fr/translation.json | 4 +- .../public/locales/hi/translation.json | 4 +- .../public/locales/id/translation.json | 4 +- .../public/locales/it/translation.json | 4 +- .../public/locales/ja/translation.json | 4 +- .../public/locales/ko/translation.json | 4 +- .../public/locales/ms/translation.json | 4 +- .../public/locales/nl/translation.json | 4 +- .../public/locales/pl/translation.json | 4 +- .../public/locales/pt/translation.json | 4 +- .../public/locales/ru/translation.json | 4 +- .../public/locales/si/translation.json | 4 +- .../public/locales/sv/translation.json | 4 +- .../public/locales/ta/translation.json | 4 +- .../public/locales/th/translation.json | 4 +- .../public/locales/tr/translation.json | 4 +- .../public/locales/uk/translation.json | 4 +- .../public/locales/vi/translation.json | 4 +- .../public/locales/zh-CN/translation.json | 4 +- .../public/locales/zh-TW/translation.json | 4 +- apps/readest-app/src-tauri/Cargo.toml | 1 + apps/readest-app/src-tauri/src/discord_rpc.rs | 182 ++++++++++++++++++ apps/readest-app/src-tauri/src/lib.rs | 42 ++-- .../src-tauri/src/macos/apple_auth.rs | 8 +- .../src-tauri/src/macos/safari_auth.rs | 7 +- .../components/ProofreadRules.test.tsx | 7 +- .../app/reader/components/FoliateViewer.tsx | 8 + .../app/reader/components/ReaderContent.tsx | 7 + .../reader/components/sidebar/BookMenu.tsx | 27 ++- .../src/hooks/useDiscordPresence.ts | 68 +++++++ apps/readest-app/src/libs/storage.ts | 6 +- .../src/pages/api/storage/upload.ts | 37 +++- apps/readest-app/src/services/appService.ts | 20 +- apps/readest-app/src/services/constants.ts | 3 + apps/readest-app/src/types/settings.ts | 10 +- apps/readest-app/src/types/system.ts | 8 + apps/readest-app/src/utils/discord.ts | 139 +++++++++++++ apps/readest-app/src/utils/image.ts | 128 ++++++++++++ apps/readest-app/src/utils/object.ts | 21 +- 50 files changed, 794 insertions(+), 85 deletions(-) create mode 100644 apps/readest-app/public/icon-tiny.png create mode 100644 apps/readest-app/src-tauri/src/discord_rpc.rs create mode 100644 apps/readest-app/src/hooks/useDiscordPresence.ts create mode 100644 apps/readest-app/src/utils/discord.ts 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 0000000000000000000000000000000000000000..2b7f213d1ff058ab8f7e144bed18d5a9b77a3472 GIT binary patch literal 52719 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
^XJH_Yf7hLzsNf8M)5L(;-zT%m?akvM zw?F!jTbEu(;soOfbEnR6>V=n>T3%)}X?M~gmX+wG;!2&iHk`_Mm^>km2gKb&`UeN} z_jic9UDDn@asQAsPSB}F87JH*Hceh*No6E3mhxP_Nz=@zIRa`(O4o98T`5B`p&Ul3 z#3|O$C8SAA9w&AVO|Q#fZ;SrkU50y`Wc?fw=-u6*cV~lJKrlVS z{Fx_MIQs}wt0(LTh+>Xr5!JPa*JwMamQ0ZA=_7J9YkavQWJdEs3q?|%B)tD?zsPWN z6O6%|nc>XEmsvh@771LjU+MB_udzc;${SeGKbHNSc1-{9;DFBEEe}+CE zH&sWw**e;`Ct*gsSUIlMArbKfB|gdvQ9*!;nyARf4;X}F`Rt9d8jDU{bBiW)Ett)=A(D$-=}kht5SbLS2Rx3AH?{Si9O zNp}y~-1&9xzV=nZ#S^SN`3y@BKTbF^XWvstqY_8i{!}f%`!#4-=rf8OFsMlUim#fZ z2#F64$;nxH@;OdFeZgu#m5jI5C|3-xmQ+mSNRYz#4~h3Rhj+F(*tkx2`zA@hhebeQ zn+d?Sj2pl=G{Qx~nFii;i>Nter=dmD_+f|_Xaav06{RRYLZpos#E9oxVSRaCgqiT4 zR7w#=>B$+R_Ap6q)i7Q);wg@fm>dlpw1iMz5jj{*sHq74tV+?`M`ego`)Ni!#^MGW zxg5ZZ4Re(mxgs@s-R_>SrSL-!qZO*vqP=vE=E?$7^T%n%YYEV;5L|`gxiw$E^Crs0m~Bs&i7AV1T|m zep<#0JVp>dVD{V@R*#=!?)Y(#HbzoALrp5Qzi39ME1B{=O_nj(+huR#275Q&rnkKh zsVNb(5i9%hqJY`uvoz;tY0uBooSh|XO`!r0<$I&URZZmdo0a__)f_FFP@<@wkLY?y z3n7FN$5QtzFmlMXzCS5p001BWNklGkGHJ&xIpBAi|1vET9gX-&`IH=32R zIAv@$Kv{Pxe!-0AkS0qpX-YcmlMH*V$81109FWH`>7Y+K+$T$8j84!xN9VRfpd?te z=OkzbB_3WF;5Vj;S_}BC7GYzSXl9zI-6CwyprVFDuU2$DfBs?S&p$#o_+buie$4Gl zU!!;X26QuaUjHh4@4m|Hqc3ve!bO@3s}2Hr6Bxg0aoQhaK?{?VlCZKH*!Y^W7hb{l zls&Xl6%s3TZ#GeDl5U5st5?~%c75w@q8J#m(qV<(teS|V&MB7Sz1 zKO%_onH;;-sJYX_rnF<%F;X)W z3KuNBZ*okQlE(>Yuglh9{Qu8p;DDKbgszTW-Da*iL)iHH-CSsEJoj(SPz0OLu)-IB{J29k}3o;^l2@vvT*q(os~5jy`H77Nr%(v zu#nCJ+61{OKNr;!*%I7yxVUzNk!dq^fbdo226IPHUI}wR*OTP6L#|xTxeux)_#zMg} zWNTpuKPHSfMXQ7~o{_{6@oY*moe+;sNGCHhN0c!-g7Nk?q)}}0Ba#_ul#(VfX`GTq5y{ldZKLUwbefPv5uJ?-T>rPe z54sJq@5~#;Ig~<^4bi#0LFZcASwqm4bSmTD^f+Yajz3DMN%OjS&!7W=33?w92$hBq!q)TJC9TB9~Pnh$qKP z`o@lLe0a!c_gmuO5HW-4{tnxFyKMjLUlA@YuyFG|mR`R>W4=qR)8W$heuLFF?z8pT z&p3SWbLOwUWrq5tXHyk1SDn5>6w@LfP-ujwQPhz(@6Oov)xb-PK2s}Bc6NC2>4yxr zU!qf^o0K(=H9h@>9^qKXKm<>;f z1_|+GpCpb^iL_(F)Sj)5>8(|O7{?HU=C+pSEv2vj9rQuUl~isD9Gso=l}h(agPIg< z8=du;k#S$wI`^FQ_`4fH{2hWYmc)RY*(y;=*wZ%TY_LgnkeYEp3tr%8cazWlkN+MS zMW#*2sWx^@Bn8w0!uA}k#f#K?#v?TBbf`C5$gpND?}XFrTXu(X0!-W!RL2F|CSzvk z%1J3HSpL#88@UF4YlX(ry8T{eR52S280>6uviXR?_EXd}COX<<`>+2mc7OgSbS~dy z<^H>LFI^+-EpYYM{~pV4yhG6L6&%+y)_T>myI})b#uSP*lijYBF5Is3jXVS8Gd|hl z`L8~rzxmj-QAp~&9&2yC!{XJ~2pV16UsaNbWMt4{rO;8t`1F+F&X~dRV}?gN#G^4P zvYRV4pIO+=uT=ra={$eW0gQw&D6=m?dW3;TSZ~u=S;1IRObc$YOG(Rnx8gYsUOZ!O z^Fyxx)^FNUzG_85g$kKf2IkiqZPJlq6Xl&@`?CD0UkNH&>HLvGql7ACLNc+O%<9P{ zD<7r&@4YaDB(iz!K0#}a=F&RNwTmfC>_#2a;YZRRI0lsUEa z%-nJ+ELa;Er&Bwuirl10+GJO|pk)Tos4{XXBr>ScSgO-lTxR9YeUj;f)9sfWJ$gX@ z*(3CHO8?0N`p>_lwSI;5cYj3p%5_?67t82ez6nDLTST{co#joem*b!oXz?V1$nqG? zLYj_FdH&!bM^8Ss?q5(_Sm5H@9~fh%FtDajN%`Q>#`+@~9@5`Aroa81)9t4uBlC{P zAFVXsOK61e<2M_G%?6EDm!Q?btJewZHG;5?3`1lX;`<)bmq;1lc@pV);QKb4!-rYMnOqR?|qXOCt^IdX{ZGFLBfo_GPFP zl}=G`B4@#<+&o>79WsTfY#PnjefEg$&pw6G7*Bcxy%rbm zf1l;ot|Pem9AE30LPgPh{%x@3E0rH(FQ^bE3} zpj~6$eBkz~+5VB zADd+WQEC_-(gQD;qc*olb8&^%(mL(=d1{?5vfe5_rxYMU!p=NvZ-0-KH{ao8^C?>& z{{_?imrQq_^5~!c&&=I?hnv6mA0WayXSI`QX{MD+XU+uNn8Cf)lF7+F&p-W;>Grlc z`$C`fd+)Gu=dO`H<$P6plPg6$8gTgZ8T;RS$>?Ygtuk?jd0zE}1?JW+(pleNZe^KT zt7+4Vl~WR`bLe8<_4B~_0jTF}MAZN}DmJWeM;%kp5H2n8`sLR-t2?zrcUK#j;S`qn zB&@@`uw-qmg)`!L#XMnbFcsvZ2Zh!iTBlC3se1|~PTj35r=R<Y}F*)94c5+M_O;9>6jFprLjuDnXD@2Bj0-iwn9x^btgPtEE{Qy5`gVZP# z8XutznKzAN(s)9W&QM8$N>i(hGDvGO8Iqh17`=STz7z;4@f&U0%U9`MxkBf{1#0b8 z@FHt8C6M(x3pa1myK;@A&Bwg_#h)_S+9nxAMJT0^XXm6Pos31zd$oqoBTNVTSWAH7_qaT@N2;q>`Pe(%qb@vh1Db zryEvHV?aY=E@kwMT4|@VF&(xP(^u#Q^dc=1U4*lUj7cEPmK648(XRaCJpGshsoRvE zWISMWdc@>tkIBJ4ll@I5$497W21yF3Dta(mmSnh!@oZ@kc9sd}JA{oE^~M}wt4B~5 z1VJ4yFt@bu3Nms5<2MS&tyH2hv@ESPq^V)@M^mEVjA{SK%pVW;n4IpDjwk3OB|hzQ zJp7cSr=OuiNqglYOV{3D{?awVxy4*>DC>2WZobLDi`K@!0#*sf62r!R z>r4nM%%LH1MnFQ~`NFiQtp=Ch`98~6UdIaqgCeKec(^7hr&}j%J@_U4t#2{WW)cxd zI%}&eUAseX{USlTX*R8HTDqJKMimv^;u#(*#aKzCHdJ*6;>i3wY1bL13$z-U+dK8p zag0(^f=>9|)u@|xoc-)7VSIE*b8(Hc4t&+%me}F_Dr;dOP&u{F9i-`3YY=ZfXnx@s z05K2XFyM~H6Ly6sx`+T@FtK9rZJL^3zP%(i8Gd$x_>4nHzlR$K7E=UlUEhr$- z%H$`E4i6dZz2tQBA+ys1n5B$gJY#d~8Jp6hbNM#Qx9-xr^cu3(0MEm#*IB#&uC4W4 zMR*pBBFn&1*1OolL*d(^)GLBU7Z!@=BkcFgl7cg_s}_}8Oy)8ql`2#clTIf@(<$+6MmiO!WI!Cv zNa87Jq|i}}iYCO93A5>hBr%nSP89RkuJgw4{JwKmI}938r5&1Qq9ze9K6%W|zjvY1 zTMEubRlA!w8%>z6txE<_*`HRg5EU&A5ljv0r5y_bVKX4KDyPuu?`p1fX|n`=4XqSu zY7Am)?PcmqJz5JFs4p(j>dxDZ)-Z^f`7nhEucsZO?QCP&#hm#>s!Xevpi^za7**&P z3~7X}fwa;ePa5U4l;xPUOg$kzgcQ_zU245~7OuYzAN+{vV8CGeB}Y#lFxYy6n#T0M z{*seNpHN#|XYJh|v3%nVyk@gdca%=*st(USLs=-|1wI0a!upvg`}-2sZr;L^UXlEy z6Z*Tmy!iAZ1}`=(DMnCRS!Vt2eU`3V#jiJSR(rr23INZo#bicW7`LgiLKvv7ktRew7sQ-b@#IE;kHUIBtrXzuiz+R~m0YFgMS&-~Eqi z&i80^dIWW&*DFbev^#EaLogGVX_ClrJ(KZ>*?4RMAg3eZ@hS0SL>eXNS%gv%N+qZ? zwmnZ=4221iJ|-U`XyAEmf~Fv>cL>@&!j|QLwp)bFIb)`q-LMGoed-Gf)EAamzVjB* zu+PcnQ}(|2kjddo;)5MF|M-7n>!&NMeecJt-F*vLZ#tt0ugolQ?I$!Pl9Vmr?6=lA zgf3Jjr%^e-O^!Iid z>}@kS-Y1!kixFM6yt6zn=@sCay{&|2=Cm^K@cj_a4-tNds5S5c4;j|*eUI+?x^v@U zH=lIQ?JV!ZQtSlCT8*{acPY7F=Vhu}j{1~*L7h@lAQ9T7`jIoeUZFXl`2l54L{m1w z*2X8>zzmVo=RIc(fdkCQf{B1f&}?ww?YGVEtEBL|!1jI`bea%POn!MZ=riq)i3j^k z`iI2hQI3tn_Kp8~Py^Kr%u)wR&QSYqN z-dLxzv`TYf8Nc1O1F|%Cds}()ZI*A{;biM2+aLcWk2H?#oKENbZy^OTl=$^Jjn+Itr$^9g5H{QR zjRt<;69f(Xuz?JeiJq1kA$=2_CNuIzv6+e)>zS#ueFu(phD{?Do=-5>E;g;E==WUE zFV6)`3(YsbkoE#pmhNQP6T+&I^9Eg3HnA=vquHse zH7ZU>hXba=6DCIoj1G^P9_}+a-6D;~FcSzrM#a9#iWg2TlNNx4$)5}G!vI+e@q-p& zt%@Jq{r6bEc^j`@GXrgJR+;gS z(6btolb0Mk`5K32^k;v{>Yev(RG6xq_|@m;*$6mU8qOE;B#6gjzWkGaj+`m*B)!*ea_znM zX>_|5X%z;MOp6{X9uGKpzRBLh&zbG-nK!4U894bK&4m@Zm##9mx=wR`o}k$>w~%yh zCbwkH7DEZXuO>UC<*?F{c-Hy(vyw%q3Ln%BFwL}wY56eD|Gj^9<|yTES{*M4EtSfBKMp!ah1-ae882MR0(q8m=$P;d-^cSq8VyBbevwZ0 z_mxq_PmYi2Z$IPY&!VKWa-S_s_Yi=9gEfJF?G$3AiOME*8yYf3|LD_ z*`sG5gv=+_k+mtWj3DRbW>QcSOE;@mw!F7xuaMe;z?sLN_`7NW|E_vKHD@5X~mx$_466E6gn}(^y)f+3Dfc8z$0N3eyj|B}N9yWHO{P^PdX( zrekYO5$AU5+R9c8s%;hbLKc?sS(gI$l$7(P&M3=~1kzKizWFvQ zuic{7?1C3tazR$IC`Km-Y<=}5hflvm#3?9^Cj`yqb(U}3Vd3&66IWf#nC#bgE%Q}T zEeMBhmbD>q4V9jfq%ldNOeks+Svk^_I7vxo5o(s8lZbRSWfsp!W>fsS&#mA5oq}mo z9V-YoY1dXS-Vx9(qn+YA;>U9-Rp^xZS^8A*oO7Ef-FT(ufLaOTM3$+BjVY|V!DLy| z^PQ@QVk6ai3%vhN{yU^^Btzy@ON$H$?Jj`Cu|!V{0-L7jct$iev!!G_Bbts#rbCj+ z#PUI>q)CKU5n3r!>fF>qAf-Tx7BWcif(BmDCTzCx>Md%m7GbMxMj?&16{pq;v1X>2 z@FcZvkJWCMm0NEzJw4*^@z?A<_$5id&*bG(9&K$>zj&2PKlmMbSFRvr+s@it-<%4$ z-2R^N>T*wd2lW#mq|f?|yNE#A+uQWEQfX$xKF`1Sf`dn&*eEaXf`Fx~@3DI8GL3~L z`*R6r=9ZP~wwzNVI+_@J$YIQ6V4cwVr%Z<@%*G~+Q^nRI5>Q%M2N6*a>b0

JyzF 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, + 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); } };