From 0a11fe39828486736448696fbc729317b7fd232f Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Sun, 29 Mar 2026 15:38:39 -0500 Subject: [PATCH] Feat: Discord RPC --- flake.nix | 2 +- package.json | 3 +- pnpm-lock.yaml | 12 ++++ src-tauri/Cargo.lock | 47 ++++++++++++-- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 8 ++- src-tauri/src/lib.rs | 1 + src/App.svelte | 19 ++++++ src/components/reader/Reader.svelte | 14 +++++ src/components/settings/Settings.svelte | 9 ++- src/lib/discord.ts | 81 +++++++++++++++++++++++++ src/store/state.svelte.ts | 2 + 12 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 src/lib/discord.ts diff --git a/flake.nix b/flake.nix index f3454e9..692628a 100644 --- a/flake.nix +++ b/flake.nix @@ -71,7 +71,7 @@ inherit version; src = frontendSrc; fetcherVersion = 1; - hash = "sha256-4QUSgWgMu7FGn44+TGmACheokPhaBdHvA/055SqUs0Q="; + hash = "sha256-ezlckHhfSIe/Hs30tbiN0a/EuvGxhO5L020aup23Ozg="; }; buildPhase = "pnpm build"; diff --git a/package.json b/package.json index 7296331..c044e04 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@tauri-apps/plugin-shell": "^2.3.5", "clsx": "^2.1.1", "phosphor-svelte": "^3.1.0", - "svelte-spa-router": "^4.0.1" + "svelte-spa-router": "^4.0.1", + "tauri-plugin-drpc": "^1.0.3" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c458ca9..bee77ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: svelte-spa-router: specifier: ^4.0.1 version: 4.0.2 + tauri-plugin-drpc: + specifier: ^1.0.3 + version: 1.0.3(typescript@5.9.3) devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^4.0.4 @@ -744,6 +747,11 @@ packages: resolution: {integrity: sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==} engines: {node: '>=18'} + tauri-plugin-drpc@1.0.3: + resolution: {integrity: sha512-vl5dXhjKbl7+Nf9veW12usdmIUtZXwEf91SzxQPZlbRRJ/sjizbbQlnkUTtx6baJuGzz0KXXgP9xUhF39BdiXQ==} + peerDependencies: + typescript: ^5.0.0 + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1364,6 +1372,10 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 + tauri-plugin-drpc@1.0.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfb202b..d29495c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -290,7 +290,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid", + "uuid 1.23.0", ] [[package]] @@ -2112,6 +2112,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-build", + "tauri-plugin-drpc", "tauri-plugin-http", "tauri-plugin-os", "tauri-plugin-process", @@ -3346,6 +3347,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpcdiscord" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71aa9a2097dc0176805e24debcb5d3ea5a17b796cd1d28e76b29f78fb49d7d5d" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "uuid 0.8.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3490,7 +3504,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid", + "uuid 1.23.0", ] [[package]] @@ -4270,7 +4284,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid", + "uuid 1.23.0", "walkdir", ] @@ -4305,6 +4319,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-drpc" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b291669b7dbc05471fba380eeecf31e3f733ae6013aaa5216a43ca376027e5a" +dependencies = [ + "log", + "rpcdiscord", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.5" @@ -4518,7 +4548,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "url", "urlpattern", - "uuid", + "uuid 1.23.0", "walkdir", ] @@ -5023,6 +5053,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[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.23.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a84aff..847c15d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ walkdir = "2" sysinfo = "0.32" dirs = "5" tauri-plugin-os = "2.3.2" +tauri-plugin-drpc = "0.1.6" [profile.release] codegen-units = 1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 02505fc..6de83d1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -32,6 +32,12 @@ "process:default", "process:allow-restart", "http:default", - "http:allow-fetch" + "http:allow-fetch", + "drpc:default", + "drpc:allow-is-running", + "drpc:allow-spawn-thread", + "drpc:allow-destroy-thread", + "drpc:allow-set-activity", + "drpc:allow-clear-activity" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cdd14be..b761fed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -527,6 +527,7 @@ fn restart_app(app: tauri::AppHandle) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_drpc::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_http::init()) diff --git a/src/App.svelte b/src/App.svelte index 74d330d..8badeac 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,7 @@ import { gql } from "./lib/client"; import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import { store, addToast, setActiveDownloads, setSettingsOpen } from "./store/state.svelte"; + import { initRpc, setIdle, clearReading, destroyRpc } from "./lib/discord"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import Layout from "./components/layout/Layout.svelte"; import Reader from "./components/reader/Reader.svelte"; @@ -263,6 +264,7 @@ cancelProbe = true; unlistenResize(); unlistenScale(); + destroyRpc(); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (idleTimer) clearTimeout(idleTimer); if (pollInterval) clearInterval(pollInterval); @@ -277,6 +279,23 @@ return () => clearTimeout(timer); }); + $effect(() => { + if (store.settings.discordRpc) { + initRpc(); + } else { + clearReading(); + destroyRpc(); + } + }); + + + // When the reader closes, show idle presence. + $effect(() => { + if (!store.activeChapter) { + if (store.settings.discordRpc) setIdle(); + } + }); + function handleRetry() { failed = false; notConfigured = false; diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte index 9d99ec7..5dc26d1 100644 --- a/src/components/reader/Reader.svelte +++ b/src/components/reader/Reader.svelte @@ -5,6 +5,7 @@ import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; + import { setReading } from "../../lib/discord"; import type { FitMode } from "../../store/state.svelte"; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -190,6 +191,19 @@ : store.activeChapter ); + // ─── Discord RPC ────────────────────────────────────────────────────────────── + // displayChapter already handles both single/double (store.activeChapter) and + // longstrip auto-next (visibleChapterId) — so reacting to it here means RPC + // updates on every chapter transition regardless of reading mode. + + $effect(() => { + const chapter = displayChapter; + const manga = store.activeManga; + if (store.settings.discordRpc && chapter && manga) { + setReading(manga, chapter); + } + }); + const adjacent = $derived.by(() => { const ref = displayChapter ?? store.activeChapter; if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] }; diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index a36d247..4c6b71d 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -775,6 +775,13 @@ +
+

Integrations

+ +
@@ -1778,7 +1785,7 @@

Links

GitHub → - Discord → + Discord →
diff --git a/src/lib/discord.ts b/src/lib/discord.ts new file mode 100644 index 0000000..b6a821a --- /dev/null +++ b/src/lib/discord.ts @@ -0,0 +1,81 @@ +import { start, stop, setActivity, clearActivity } from "tauri-plugin-drpc"; +import { Activity, Assets, Button, Timestamps } from "tauri-plugin-drpc/activity"; +import type { Manga, Chapter } from "./types"; + +const APP_ID = "1487894643613106298"; +const FALLBACK_IMAGE = "moku_logo"; + +function isPublicUrl(url: string | null | undefined): boolean { + return typeof url === "string" && url.startsWith("https://"); +} + +function resolveCoverImage(manga: Manga): string { + return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE; +} + +function trunc(s: string, max = 128): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} + +function formatChapter(chapter: Chapter): string { + const n = chapter.chapterNumber; + return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`; +} + +const BUTTONS = [ + new Button("GitHub", "https://github.com/Youwes09/Moku"), + new Button("Discord", "https://discord.gg/Jq3pwuNqPp"), +]; + +export async function initRpc(): Promise { + await start(APP_ID) + .then(() => console.log("[discord] RPC started")) + .catch((e) => console.error("[discord] initRpc failed:", e)); +} + +export async function setReading(manga: Manga, chapter: Chapter): Promise { + const assets = new Assets() + .setLargeImage(resolveCoverImage(manga)) + .setLargeText(trunc(manga.title)) + .setSmallImage(FALLBACK_IMAGE) + .setSmallText("Moku"); + + const activity = new Activity() + .setDetails(trunc(manga.title)) + .setState(`${formatChapter(chapter)} · Reading`) + .setAssets(assets) + .setTimestamps(new Timestamps(Date.now())); + activity.setButton(BUTTONS); + + await setActivity(activity) + .then(() => console.log("[discord] reading →", manga.title, formatChapter(chapter))) + .catch((e) => console.error("[discord] setActivity failed:", e)); +} + +export async function setIdle(): Promise { + const assets = new Assets() + .setLargeImage(FALLBACK_IMAGE) + .setLargeText("Moku"); + + const activity = new Activity() + .setDetails("Browsing") + .setAssets(assets) + .setTimestamps(new Timestamps(Date.now())); + activity.setButton(BUTTONS); + + await setActivity(activity) + .then(() => console.log("[discord] idle")) + .catch((e) => console.error("[discord] setActivity failed (idle):", e)); +} + +export async function clearReading(): Promise { + await clearActivity() + .then(() => console.log("[discord] activity cleared")) + .catch((e) => console.error("[discord] clearActivity failed:", e)); +} + +export async function destroyRpc(): Promise { + await stop() + .then(() => console.log("[discord] RPC stopped")) + .catch((e) => console.error("[discord] destroyRpc failed:", e)); +} diff --git a/src/store/state.svelte.ts b/src/store/state.svelte.ts index c450bb3..9aea06c 100644 --- a/src/store/state.svelte.ts +++ b/src/store/state.svelte.ts @@ -180,6 +180,7 @@ export interface Settings { libraryCropCovers: boolean; libraryPageSize: number; showNsfw: boolean; + discordRpc: boolean; chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number; @@ -252,6 +253,7 @@ export const DEFAULT_SETTINGS: Settings = { libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, + discordRpc: false, chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,