Compare commits

..

134 Commits

Author SHA1 Message Date
Youwes09 615fa1e92f Chore: GQL Cleanup P.3 2026-06-07 18:37:52 -05:00
Youwes09 248b046627 Fix: Home-Screen Recommendations & GQL Cleanup P.2 2026-06-07 15:40:18 -05:00
Youwes09 79e5548879 Fix: Library Filtering + GQL Cleanup P.1 2026-06-07 00:18:45 -05:00
Youwes09 ed4c11ca7e Fix: Local-Source Popular Query + App-Pin Flow 2026-06-06 15:00:59 -05:00
Youwes09 5dfbc80bbe Fix: App Pin & Downloads (Filesystem Changes) 2026-06-05 17:42:32 -05:00
Youwes09 8aa92e6b54 Chore: Update Dependencies 2026-06-03 22:38:07 -05:00
Youwes09 b55dd16d0d Fix: Update to FetcherVersion 3 for NixOS 2026-06-03 22:12:42 -05:00
Youwes09 7c6aeb8f4c Chore: Re-make README + Stack Update 2026-06-03 21:49:01 -05:00
Youwes09 3e4d322fb7 Chore: Fix Nix Build (Improper) 2026-06-03 21:37:34 -05:00
Youwes09 db8a984270 Chore: Remove Old Directory (Prepare for Patches) 2026-06-02 20:04:01 -05:00
Youwes09 18027baee1 Chore: Completed Splash-Screen & Iniital Tauri Wire-Up 2026-06-02 08:27:37 -05:00
Youwes09 c5243ba30c Chore: Port over Reader & Tracking 2026-05-31 21:14:25 -05:00
Youwes09 13f2a483ca Chore: Port over Extensions & Search 2026-05-31 00:30:36 -05:00
Youwes09 6de5207ce7 Chore: Port over SeriesDetail + Panels 2026-05-29 20:07:07 -05:00
Youwes09 8c250021a0 Chore: Port over SeriesDetail (WIP Panels) 2026-05-28 23:05:02 -05:00
Zerebos 584b917f98 fix: Data-theme attribute on document body 2026-05-25 20:59:24 -04:00
Youwes09 e9929747d2 Chore: Fix Zoom & Attempt Theming 2026-05-25 12:31:23 -05:00
Youwes09 cbdf9e8be1 Fix: Stub Capacitor, Fix Tauri-Build, Fix Static-Build 2026-05-25 10:21:12 -05:00
Youwes09 d9a9427e3b Chore: Port over Settings (Barely Works) 2026-05-24 20:31:46 -05:00
Youwes09 ae5d9748c7 Chore: Port over Home & Fix Suwayomi-Server Detection on Web 2026-05-24 12:09:29 -05:00
Youwes09 6c39ef538f Fix: Splashscreen Appears on Boot 2026-05-22 21:39:29 -05:00
Youwes09 081becdd60 Chore: Basic Layout/Chrome + Stubs (WIP) 2026-05-22 21:30:40 -05:00
Youwes09 c891cb349c Chore: Implement Server Adapters & Request Manager 2026-05-22 20:44:55 -05:00
Youwes09 8cef74bb98 Chore: Restructure Repository for SvelteKit 2026-05-22 04:04:59 -05:00
Shozikan bf071dcfc7 Chore: Merge pull request #92 from zerebos/feat/update-panel
Feat/update panel
2026-05-21 22:56:37 -05:00
Youwes09 da788e90ba Feat: Manual Binary-Selection (CSS-WIP) (#91) 2026-05-21 14:37:53 -05:00
Zerebos b0efb183e8 Poll when updating on server 2026-05-21 02:43:06 -04:00
Zerebos 745b6993de Actually grab status from server 2026-05-21 02:33:06 -04:00
Zerebos bd79169f71 Basic caching 2026-05-21 02:23:09 -04:00
Zerebos 6fccf02614 Single line stats 2026-05-21 02:12:21 -04:00
Zerebos fa7cfdc4e6 Use stats boxes on history page 2026-05-21 02:12:21 -04:00
Zerebos 9c614b38f8 More parity between panels 2026-05-21 02:12:21 -04:00
Zerebos 30e50b5a1b Match update cards to download items 2026-05-21 02:12:21 -04:00
Zerebos 8ef0a14363 Add tab icons 2026-05-21 02:12:21 -04:00
Zerebos 4e2ad6cae7 Hoist toolbar into Recent, add status bar, dim read chapters, split cover click 2026-05-21 02:12:15 -04:00
Zerebos 9e56b1176c Integrate updates into recent activity page 2026-05-21 02:12:07 -04:00
Zerebos d025d07e07 Fix updates page data flow 2026-05-21 02:12:01 -04:00
Zerebos f988641446 Add updates page scaffold 2026-05-21 02:11:20 -04:00
Youwes09 3dad4bc729 Feat: Re-Arrangement of Folders (#86) 2026-05-19 20:36:44 -05:00
Youwes09 1af21efebd Feat: Download Storage Threshold Warning (#88) 2026-05-19 20:07:21 -05:00
Youwes09 b7197a09a7 Fix: Attempt to Patch UI Login (Not-Working) 2026-05-19 19:25:43 -05:00
Youwes09 50dd8d7e35 Fix: Preserve Token for NONE Path 2026-05-19 18:55:44 -05:00
Youwes09 b2eaea6552 Merge branch 'main' of github.com:moku-project/Moku 2026-05-19 18:53:47 -05:00
Shozikan 35aae6d85a Chore: Merge PR (#78)
Rework authentication for smoother switching between servers and auth mode
2026-05-19 18:52:48 -05:00
Youwes09 28e5f5625e Merge branch 'fix/auth' 2026-05-19 18:15:00 -05:00
Zerebos b99e4d9a3d Cleanup logs 2026-05-19 02:32:34 -04:00
Youwes09 d5f50c6495 Merge branch 'main' of https://github.com/Youwes09/Moku 2026-05-18 00:32:04 -05:00
Youwes09 89cfa50aff Fix PKGBUILD (V2) 2026-05-18 00:30:33 -05:00
Youwes09 5e591411e4 Chore: Patch PKGBUILD for AUR (V1) 2026-05-17 16:50:40 -05:00
Youwes09 8aaaf2451a Fix: Added ServerBinary Off & Flatpak Patches 2026-05-17 16:27:57 -05:00
Zerebos 75cc767b58 Expiry formatting change 2026-05-17 04:11:33 -04:00
Zerebos d30c623200 Better formatting for dates 2026-05-17 04:08:46 -04:00
Zerebos 017e9bc6da Authenticated fetch jwt settings 2026-05-17 04:00:34 -04:00
Zerebos 3b8088a2bf Decode ISO-8601 2026-05-17 03:50:39 -04:00
Zerebos 2c5320dd1f Some debug logging 2026-05-17 03:31:17 -04:00
Zerebos 1e35f304b6 Add auth debug in devtools 2026-05-17 03:29:27 -04:00
Zerebos 61339ea006 Implement jwt with refresh 2026-05-17 03:04:23 -04:00
Youwes09 f161fc08a2 Chore: Post-Bump 0.9.4 2026-05-17 00:14:22 -05:00
Youwes09 239960683b Chore: Bump for 0.9.4 2026-05-17 00:12:55 -05:00
Youwes09 3b5efc85d0 Fix: ReaderOverlay Draggable 2026-05-17 00:01:55 -05:00
Youwes09 7df3846e75 Feat: Improved PageLoder & Keybinds Fix 2026-05-16 23:36:15 -05:00
Youwes09 01f123f5be Fix: GlobalUIZoom Affecting MangaDisplay (#82) 2026-05-16 23:06:10 -05:00
Youwes09 0e2371096b Feat: Basic ExtensionLibrary Filter 2026-05-16 22:50:00 -05:00
Youwes09 47ae80a7d2 Fix: Cache Adjustments (WIP) 2026-05-16 22:46:45 -05:00
Youwes09 d98547d540 Fix: Cache-Boot (KCEF Corruption) 2026-05-17 03:29:39 -05:00
Youwes09 897ecfd316 Fix: Clear Moku Cache & SelectPortal Zoom (#82) 2026-05-16 22:05:13 -05:00
Youwes09 e3abc72f1b Fix: Duplicate App Instances (#83) 2026-05-16 15:41:07 -05:00
Youwes09 6b56db7cf2 Fix: Exit Button Works 2026-05-16 15:31:13 -05:00
Youwes09 93cedca6b5 Chore: Post-Bump 0.9.3 2026-05-16 08:00:11 -05:00
Youwes09 9f8bf6ffc1 Chore: Tagged 0.9.3 V2 2026-05-16 07:59:35 -05:00
Youwes09 39f813b4d7 Chore: Tagged 0.9.3 2026-05-16 07:57:26 -05:00
Youwes09 18ac38e888 Feat: Disable Auto-Complete on Moku 2026-05-16 07:56:05 -05:00
Shozikan 1e2e923eab Chore: Merge pull request #79 from zerebos/feat/page-loaders
Add circular loaders to pages
2026-05-16 07:41:28 -05:00
Youwes09 d3a40b9152 Fix: System.rs PathBuf Error 2026-05-16 03:54:05 -05:00
Zerebos b1444582a3 Add circular loaders to pages 2026-05-16 01:01:12 -04:00
Zerebos bee8117aac Don't introduce a new key 2026-05-16 00:01:21 -04:00
Zerebos 0bea9c22cb Rework auth to allow smooth switching 2026-05-15 23:50:19 -04:00
Youwes09 bf3f68b996 Feat: Minimize to Sys-Tray + MultiModal (#76) 2026-05-15 21:21:31 -05:00
Youwes09 4b728ad5b7 Feat: Middle-Click for Browser-Auto-Scroll (#70) 2026-05-15 20:41:21 -05:00
Youwes09 f3f91f1555 Feat: Auto-Scroll & Double-Tap Adjustment (#69) 2026-05-15 20:36:15 -05:00
Youwes09 062662781a Feat: Bulk-Source Migration (#66) 2026-05-15 19:49:26 -05:00
Youwes09 cbf8a7fe13 Feat: Extension Settings & Library Filtering (#73) 2026-05-15 19:29:00 -05:00
Youwes09 5af80213c7 Feat: Extension Settings & Library Filtering (#71) (#72) 2026-05-15 07:19:21 -05:00
Youwes09 17d739a1cd Fix: Drag-Region for Reader Bar (#74) 2026-05-14 08:07:14 -05:00
Youwes09 2867dc9612 Fix: Direct-Mouse Scroll (#75) 2026-05-14 08:01:17 -05:00
Youwes09 a9dc047b44 Fix: Toolbar Uniformity & SeriesDetail Redirect (#66) 2026-05-11 20:47:37 -05:00
Youwes09 ef190ae66f Fix: LibraryToolbar Folder Drag 2026-05-11 14:35:05 -05:00
Youwes09 6d921944ac Fix: Library FolderSetting Re-Vamp 2026-05-10 12:07:00 -05:00
Youwes09 244447da9b Feat: Backtracing + NavPage Store 2026-05-10 04:31:27 -05:00
Youwes09 f05f781b5b Fix: Biometric Revision V1 2026-05-10 03:00:08 -05:00
Youwes09 f7c5aebf29 Fix: PerformanceSettings RenderLimit CSS Revision (#63) 2026-05-10 02:50:48 -05:00
Youwes09 e09ae9d2e7 Fix: Respect Page-Order in Loading & Memory Eviction (#61, #63, #68) 2026-05-10 02:17:25 -05:00
Youwes09 7b2ae74c02 Fix: Trigger Recently-Fetched Data for RecentActivity (#63) 2026-05-03 13:06:02 -05:00
Youwes09 0d53e3f102 Fix: Attempt to Improve UI-Login Cache (#63) 2026-05-03 12:29:20 -05:00
Youwes09 093b395cc1 Fix: Re-Try UI Login Token + GQL Wait 2026-05-03 11:47:18 -05:00
Youwes09 efdd8ff95d Fix: Re-Register Settings Export Function (#63) 2026-05-03 11:35:09 -05:00
Youwes09 c0f0ff9bd3 Fix: TrackingSync Excludes Decimals & Respects Chapter Numbers (#63) 2026-05-02 18:14:34 -05:00
Youwes09 3f6049c12d Fix: Remove Scroll Propagation in Reader (#63) 2026-05-02 18:06:37 -05:00
Youwes09 5451a2654b Fix: Wrap ReaderControls in Scrollable (#63) 2026-05-02 17:58:16 -05:00
Youwes09 e625755c5e Fix: Library Folders Clipping (Anim Removed) (#63) 2026-05-02 17:51:54 -05:00
Youwes09 bd95bf4eb1 Fix: Added Download Toggles to Global-Store (#63) 2026-05-02 17:40:07 -05:00
Youwes09 b4d680ddd1 Fix: Error-Handling & ScrollBox on TrackingSettings (#63) 2026-05-02 17:34:54 -05:00
Youwes09 d1b7429b5d Fix: FolderSettings Revamp & Folders (#63) 2026-05-02 17:23:47 -05:00
Youwes09 000195be89 Fix: State-Based Issues & AboutSettings (WIP) 2026-05-02 16:53:50 -05:00
Youwes09 399d429142 Fix: Rust-Cleanup & Flake-SHA Patch 2026-05-01 11:32:29 -05:00
Youwes09 b79ee99e8a Fix: Linked CORS Bypass to UI-LOGIN 2026-05-01 11:09:29 -05:00
Youwes09 80c4b9d9be Chore: Update pnpm-tauri Packages 2026-05-01 01:14:39 -05:00
Youwes09 4584e6e69e Chore: Post-Bump for v0.9.2 2026-05-01 01:09:41 -05:00
Youwes09 83711c155d Chore: Prepare & Update for v0.9.2 2026-05-01 01:07:10 -05:00
Youwes09 3702a25813 Chore: Patch Biometrics 2026-05-01 05:56:39 -05:00
Youwes09 a71cc719ba Chore: Patch all Svelte-Warnings & Add Aria-Labels 2026-05-01 00:38:15 -05:00
Youwes09 1801fecdbb Feat: Windows-Hello Testing (DevTools Only) 2026-05-01 00:09:49 -05:00
Youwes09 0cd799f450 Fix: Cap ReaderSettings Zoom Value to 100 2026-04-30 23:11:39 -05:00
Youwes09 5dab7761bc Feat: Update TrackingPanel 2026-04-30 22:48:58 -05:00
Youwes09 552a11a517 Feat: Library-Refresh Overhaul & Settings Re-Wiring 2026-04-30 22:42:59 -05:00
Youwes09 c8ec6d6b90 Feat: Update Suwayomi (Stable -> Preview) + UI Login 2026-04-30 22:02:45 -05:00
Youwes09 daaeae00fe Fix: Patch Flake & PKGBUILD for Preview 2026-04-30 01:19:55 -05:00
Youwes09 79cb2f7c56 Feat: Shift from Stable to Preview (WIP) 2026-04-30 01:04:56 -05:00
Youwes09 4d3dfdbec6 Feat: Settings Reset, Data Clear, Date Fixes (#56) 2026-04-29 21:07:53 -05:00
Youwes09 78573eacb1 Feat: TouchScreen Support for SeriesDetail & Modularity Revamp (#29 2026-04-29 18:40:41 -05:00
Youwes09 1bb7da3b22 Merge branch 'main' of github.com:moku-project/Moku 2026-04-29 18:18:41 -05:00
Youwes09 dd0cf9372d Feat: Implement CSS for Chrome & Link to Context-Menu 2026-04-29 18:18:21 -05:00
Shozikan 50928c6343 Chore: Commit to Downloads in README 2026-04-29 11:22:54 -05:00
Youwes09 170493aa71 Feat: Implement Storage-based (JSON) Settings & Data-Storage (WIP) (#56) 2026-04-29 00:18:09 -05:00
Youwes09 c009bd71fc Fix: Optimized KeywordTab with BlobURL like TagTab 2026-04-28 23:45:35 -05:00
Youwes09 4df7f416a7 Feat: Revamped Content-Filtering + Levels & Source-Based Toggle 2026-04-28 23:22:29 -05:00
Youwes09 63209cb828 Fix: Futile Attempt to Implement Image-Dedupe (#55) 2026-04-28 22:45:19 -05:00
Youwes09 2c1391c378 Fix: Integrate Sync-Back on Tracker Addition (#52) 2026-04-28 22:24:37 -05:00
Youwes09 41fd4a820c Fix: Revert Logic for TrackingPanel (#58) 2026-04-28 22:17:16 -05:00
Youwes09 9f3c6d2ac3 Fix: LibraryGrid with Z-Index Applied (#57) 2026-04-28 22:09:36 -05:00
Youwes09 f5b3f76b5d Fix: Search Titles Overlay (Z-Index Applied) 2026-04-28 11:58:42 -05:00
Youwes09 528f966b1f Chore: Refactor Rust-Backend Modularity 2026-04-28 10:54:52 -05:00
Youwes09 75e8bc5986 Feat: Cover-Image Switching & Auto-Link (#55) 2026-04-28 01:22:36 -05:00
Youwes09 8123053a40 Chore: Update to Version 0.9.1 (V3) 2026-04-27 23:45:44 -05:00
375 changed files with 29236 additions and 21309 deletions
+2 -2
View File
@@ -88,10 +88,10 @@ jobs:
- name: Download Suwayomi (Linux x64) - name: Download Suwayomi (Linux x64)
run: | run: |
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
-o suwayomi-linux.tar.gz -o suwayomi-linux.tar.gz
echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c - echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 suwayomi-linux.tar.gz" | sha256sum -c -
mkdir -p suwayomi-extracted mkdir -p suwayomi-extracted
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1 tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
+5 -5
View File
@@ -79,7 +79,7 @@ jobs:
download_suwayomi() { download_suwayomi() {
local asset="$1" sha="$2" outdir="$3" local asset="$1" sha="$2" outdir="$3"
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
-o "${outdir}.tar.gz" -o "${outdir}.tar.gz"
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c - echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
mkdir -p "${outdir}" mkdir -p "${outdir}"
@@ -87,13 +87,13 @@ jobs:
} }
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \ "Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \ "59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
"suwayomi-arm64" "suwayomi-arm64"
download_suwayomi \ download_suwayomi \
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \ "Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \ "da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
"suwayomi-x64" "suwayomi-x64"
- name: Stage Suwayomi sidecars - name: Stage Suwayomi sidecars
+2 -2
View File
@@ -79,9 +79,9 @@ jobs:
shell: bash shell: bash
run: | run: |
curl -fsSL \ curl -fsSL \
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \ "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
-o suwayomi-windows.zip -o suwayomi-windows.zip
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c - echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
unzip -q suwayomi-windows.zip -d suwayomi-raw unzip -q suwayomi-windows.zip -d suwayomi-raw
- name: Extract Suwayomi bundle - name: Extract Suwayomi bundle
+20 -8
View File
@@ -1,26 +1,33 @@
# --- Build Artifacts ---
node_modules/ node_modules/
suwayomi-raw/
suwayomi-windows.zip
dist/ dist/
dist-tauri/ dist-tauri/
target/ target/
bin/ bin/
out/ out/
# --- Nix ---
.direnv/ .direnv/
result result
result-* result-*
# --- Logs ---
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log*
.env .env
.env.*
!.env.example
!.env.test
.env.local .env.local
.env.*.local .env.*.local
# --- IDEs & OS --- .output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
@@ -30,14 +37,19 @@ yarn-error.log*
*.sln *.sln
*.swp *.swp
# --- Tauri specific ---
src-tauri/target/ src-tauri/target/
src-tauri/binaries/
src-tauri/gen/ src-tauri/gen/
# --- Flatpak build artifacts --- .DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
build-dir/ build-dir/
repo/ repo/
dist/ dist/
packaging/frontend-dist.tar.gz packaging/frontend-dist.tar.gz
*.flatpak *.flatpak
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/ ./flatpak-builder
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+44 -25
View File
@@ -1,5 +1,5 @@
pkgname=moku pkgname=moku
pkgver=0.9.1 pkgver=0.9.4
pkgrel=1 pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server" pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64') arch=('x86_64')
@@ -13,27 +13,46 @@ depends=(
) )
makedepends=( makedepends=(
'rust' 'rust'
'cargo'
'nodejs'
'pnpm' 'pnpm'
) )
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=( source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz" "$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"Suwayomi-Server-v2.1.1867.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867.jar" "Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
) )
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=( sha256sums=(
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
'SKIP'
'SKIP' 'SKIP'
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
) )
prepare() { prepare() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
mkdir -p src-tauri/.cargo
cat > src-tauri/.cargo/config.toml << 'EOF'
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
EOF
} }
build() { build() {
cd "Moku-$pkgver" cd "Moku-$pkgver"
pnpm build pnpm build
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
fixed_cflags="${fixed_cflags/-flto=auto/}"
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
CFLAGS="$fixed_cflags" \
CXXFLAGS="$fixed_cxxflags" \
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \ TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
--release \ --release \
--manifest-path src-tauri/Cargo.toml --manifest-path src-tauri/Cargo.toml
@@ -45,11 +64,11 @@ package() {
install -Dm755 src-tauri/target/release/moku \ install -Dm755 src-tauri/target/release/moku \
"$pkgdir/usr/bin/moku" "$pkgdir/usr/bin/moku"
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.1867.jar" \ install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar" "$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf" install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF' cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = false
@@ -60,22 +79,22 @@ server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12 server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6 server.maxSourcesInParallel = 6
server.extensionRepos = [] server.extensionRepos = []
EOF CONF
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'EOF' install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
if [ ! -f "$DATA_DIR/server.conf" ]; then if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf" cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi fi
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
@@ -87,13 +106,13 @@ export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true" export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec java \ exec java \
-Djava.awt.headless=true \ -Djava.awt.headless=true \
-Dapple.awt.UIElement=true \ -Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \ -Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \ -Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \ -Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar -jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
EOF LAUNCHER
install -Dm644 packaging/io.github.moku_project.Moku.desktop \ install -Dm644 packaging/io.github.moku_project.Moku.desktop \
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop" "$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
@@ -105,6 +124,6 @@ EOF
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \ install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml" "$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE \
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
} }
+9 -9
View File
@@ -5,7 +5,7 @@
<div align="center"> <div align="center">
[![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest) [![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
[![Last Commit](https://www.shieldcn.dev/github/last-commit/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/commits/main) ![GitHub Downloads](https://www.shieldcn.dev/github/downloads/moku-project/Moku.svg?variant=outline&size=default)
[![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku) [![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72) [![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](https://discord.gg/x97hj8zR72)
@@ -33,7 +33,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
</div> </div>
<div align="center"> <div align="center">
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a> <a href="docs/screenshots">View all screenshots →</a>
</div> </div>
--- ---
@@ -46,8 +46,8 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers - **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
- **Extension support** — install and manage Suwayomi extensions directly from the app - **Extension support** — install and manage Suwayomi extensions directly from the app
- **Download management** — queue and monitor chapter downloads with progress toasts - **Download management** — queue and monitor chapter downloads with progress toasts
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail) - **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General - **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
- **Auto-start server** — optionally launch Suwayomi in the background on startup - **Auto-start server** — optionally launch Suwayomi in the background on startup
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more - **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **Auto-updates** — in-app update checker with silent background notifications - **Auto-updates** — in-app update checker with silent background notifications
@@ -61,7 +61,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff) ![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff)
![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000) ![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000)
![Runs on MacOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff) ![Runs on macOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff)
</div> </div>
@@ -148,9 +148,9 @@ pnpm tauri:dev
| | | | | |
|---|---| |---|---|
| [Tauri v2](https://tauri.app) | Native app shell | | [Tauri v2](https://tauri.app) | Native app shell |
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI | | [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler | | [Vite 8](https://vitejs.dev) | Frontend bundler |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds | | [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
--- ---
@@ -170,4 +170,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
## Disclaimer ## Disclaimer
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources. Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+2 -37
View File
@@ -1,39 +1,4 @@
Major Revisions: Revival of the TODO List!!!!!
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
- Moku-Share allows exporting of Manga
- Compressed Format (Storage)
- Import as Local-Source
- Takes existing Local-Source or Creates Own
Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
Priority Bugs: - Reminder to Completely Test Settings
- Fix Library-Refresh System (TESTING)
- Suwayomi RESET
- Allow User to Wipe Suwayomi (Scratch)
- If Possible, Component based Wipe (Library, Etc)
In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Tracking
- Fix SeriesDetail Tracking Window (Maybe Link to TrackingPanel)
- Hide Completed from Library Settting
Notes from last time:
- Currently working on #42, just need to mount panel and fix button in reader
Generated
+12 -28
View File
@@ -1,30 +1,15 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1773857772,
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1772408722, "lastModified": 1778716662,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -35,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1773821835, "lastModified": 1780243769,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -51,11 +36,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1772328832, "lastModified": 1777168982,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -66,7 +51,6 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
@@ -79,11 +63,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1773975983, "lastModified": 1780543271,
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=", "narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac", "rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
"type": "github" "type": "github"
}, },
"original": { "original": {
+56 -275
View File
@@ -2,23 +2,27 @@
description = "Moku manga reader frontend for Suwayomi"; description = "Moku manga reader frontend for Suwayomi";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
}; };
outputs = outputs =
inputs@{ flake-parts, crane, rust-overlay, ... }: inputs@{ flake-parts, rust-overlay, ... }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" ]; systems = [
"x86_64-linux"
"aarch64-linux"
];
perSystem = { system, lib, ... }: perSystem =
{ system, lib, ... }:
let let
version = "0.9.1"; versions = import ./nix/versions.nix;
version = versions.moku;
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
inherit system; inherit system;
@@ -26,11 +30,12 @@
}; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" ]; extensions = [
"rust-src"
"rust-analyzer"
];
}; };
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
runtimeLibs = with pkgs; [ runtimeLibs = with pkgs; [
webkitgtk_4_1 webkitgtk_4_1
gtk3 gtk3
@@ -46,281 +51,54 @@
gsettings-desktop-schemas gsettings-desktop-schemas
]; ];
frontendSrc = lib.cleanSourceWith { src = lib.cleanSourceWith {
src = ./.; src = ./.;
filter = path: type: filter =
let base = builtins.baseNameOf path; path: type:
let
base = builtins.baseNameOf path;
in in
(lib.hasInfix "/src" path) (lib.hasInfix "/src" path)
|| (lib.hasInfix "/src-tauri/src" path)
|| (lib.hasInfix "/src-tauri/icons" path)
|| (lib.hasInfix "/src-tauri/capabilities" path)
|| (lib.hasInfix "/static" path)
|| base == "index.html" || base == "index.html"
|| base == "package.json" || base == "package.json"
|| base == "pnpm-lock.yaml" || base == "pnpm-lock.yaml"
|| base == "pnpm-workspace.yaml"
|| base == "tsconfig.json" || base == "tsconfig.json"
|| base == "vite.config.ts"; || base == "vite.config.ts"
|| base == "svelte.config.js"
|| base == "Cargo.toml"
|| base == "Cargo.lock"
|| base == "build.rs"
|| base == "tauri.conf.json";
}; };
frontend = pkgs.stdenv.mkDerivation { suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
pname = "moku-frontend";
inherit version;
src = frontendSrc;
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ]; moku = pkgs.callPackage ./nix/moku.nix {
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
pnpmDeps = pkgs.fetchPnpmDeps { appIcon = ./src/lib/assets/moku-icon.svg;
pname = "moku-frontend";
inherit version;
src = frontendSrc;
fetcherVersion = 1;
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
};
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
}; };
cargoSrc = lib.cleanSourceWith { scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
src = ./src-tauri;
filter = path: type:
(craneLib.filterCargoSources path type)
|| (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/capabilities/" path)
|| (builtins.baseNameOf path == "tauri.conf.json");
};
commonArgs = {
src = cargoSrc;
cargoToml = ./src-tauri/Cargo.toml;
cargoLock = ./src-tauri/Cargo.lock;
strictDeps = true;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
preBuild = ''
cp -r ${frontend} ../dist
'';
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
moku = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
meta.mainProgram = "moku";
postInstall = ''
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
src="icons/''${size}@2x.png"
[ -f "$src" ] && install -Dm644 "$src" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${./src/assets/moku-icon.svg}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
});
bumpScript = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain
nodejs_22 pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
echo " Bumping version fields to $VERSION "
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
"$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
"$REPO/src-tauri/Cargo.toml"
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
"$REPO/flake.nix"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
echo "Done"
echo " Regenerating Cargo.lock "
(cd "$REPO/src-tauri" && cargo generate-lockfile)
echo "Done"
echo " Building frontend "
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build
echo "Done"
echo " Repacking frontend-dist.tar.gz "
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
echo "sha256: $FRONTEND_SHA"
echo " Regenerating cargo-sources.json "
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Done"
echo " Patching flatpak manifest (version + frontend sha256) "
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
echo "Done"
echo ""
echo "Bumped to v$VERSION"
echo ""
echo "Commit field in the flatpak manifest still points to the old tag."
echo "After pushing the tag, run:"
echo " nix run .#post-tag-bump -- $VERSION"
'';
};
postTagBumpScript = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
echo " Resolving commit for v$VERSION "
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
| awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
echo "commit: $COMMIT"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
echo "Done"
echo " Fetching PKGBUILD tarball sha256 "
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
echo "Done"
echo ""
echo "post-tag-bump complete for v$VERSION"
'';
};
flatpakScript = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [
gnused coreutils git
appstream flatpak-builder flatpak
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
echo " Building flatpak for v$VERSION "
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo ""
echo "moku.flatpak created v$VERSION"
'';
};
pkgbuildBumpScript = pkgs.writeShellApplication {
name = "moku-pkgbuild-bump";
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
PKGBUILD="$REPO/PKGBUILD"
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
echo "Fetching tarball sha256..."
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
'';
};
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = { packages = {
default = { type = "app"; program = "${moku}/bin/moku"; }; inherit moku suwayomiServer;
moku = { type = "app"; program = "${moku}/bin/moku"; }; default = moku;
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${postTagBumpScript}/bin/moku-post-tag-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
}; };
packages = { apps = {
inherit moku frontend; default = { type = "app"; program = "${moku}/bin/moku"; };
default = moku; moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@@ -331,24 +109,27 @@ EOF
wrapGAppsHook3 wrapGAppsHook3
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomiServer
cloudflared cloudflared
xdg-utils xdg-utils
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ])) (python3.withPackages (ps: [
ps.aiohttp
ps.tomlkit
]))
]; ];
shellHook = '' shellHook = ''
export NO_STRIP=true export NO_STRIP=true
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
echo "Moku dev shell pnpm install && pnpm tauri:dev" echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo "" echo ""
echo "Release workflow:" echo " nix run .#bump -- <ver> bump version + rebuild frontend"
echo " nix run .#bump -- <ver> bump all versions + rebuild artifacts"
echo " git commit && git tag && git push" echo " git commit && git tag && git push"
echo " nix run .#post-tag-bump -- <ver> patch manifest commit + PKGBUILD sha" echo " nix run .#update -- <ver> fetch hashes + patch all configs"
echo " nix run .#flatpak -- <ver> build moku.flatpak" echo " nix run .#flatpak build flatpak bundle"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)" echo " nix run .#tunnel -- [port] expose local server via cloudflare"
''; '';
}; };
+82 -17
View File
@@ -32,6 +32,77 @@ build-options:
CARGO_HOME: /run/build/moku/cargo CARGO_HOME: /run/build/moku/cargo
modules: modules:
- name: intltool
buildsystem: autotools
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
- name: libdbusmenu
buildsystem: autotools
build-options:
cflags: -Wno-error
env:
HAVE_VALGRIND_FALSE: '#'
HAVE_VALGRIND_TRUE: ''
config-opts:
- --with-gtk=3
- --disable-static
- --disable-dumper
- --disable-tests
- --disable-gtk-doc
- --disable-vala
- --disable-introspection
cleanup:
- /include
- /libexec
- /lib/pkgconfig
- /lib/*.la
- /share/doc
- /share/libdbusmenu
- /share/gtk-doc
- /share/gir-1.0
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.3
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DENABLE_BINDINGS_MONO=OFF
- -DENABLE_BINDINGS_VALA=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- type: shell
commands:
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
- name: openjdk - name: openjdk
buildsystem: simple buildsystem: simple
build-commands: build-commands:
@@ -52,9 +123,6 @@ modules:
- type: inline - type: inline
dest-filename: catch_abort.c dest-filename: catch_abort.c
contents: | contents: |
// Linux only:
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
#define _GNU_SOURCE #define _GNU_SOURCE
#include <stdio.h> #include <stdio.h>
#include <dlfcn.h> #include <dlfcn.h>
@@ -95,12 +163,12 @@ modules:
cat > /app/tachidesk/default-conf/server.conf << 'EOF' cat > /app/tachidesk/default-conf/server.conf << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -114,23 +182,20 @@ modules:
cat > /app/bin/tachidesk-server << 'EOF' cat > /app/bin/tachidesk-server << 'EOF'
#!/bin/sh #!/bin/sh
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# Seed conf on first run
if [ ! -f "$DATA_DIR/server.conf" ]; then if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf" cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -155,8 +220,8 @@ modules:
sources: sources:
- type: file - type: file
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
dest-filename: Suwayomi-Server.jar dest-filename: Suwayomi-Server.jar
- name: moku - name: moku
@@ -166,10 +231,10 @@ modules:
CARGO_HOME: /run/build/moku/cargo CARGO_HOME: /run/build/moku/cargo
XDG_DATA_HOME: /run/build/moku/xdg-data XDG_DATA_HOME: /run/build/moku/xdg-data
TAURI_SKIP_DEVSERVER_CHECK: 'true' TAURI_SKIP_DEVSERVER_CHECK: 'true'
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
build-commands: build-commands:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku - install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop - install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png - install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
@@ -179,11 +244,11 @@ modules:
sources: sources:
- type: git - type: git
url: https://github.com/moku-project/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.9.1 tag: v0.9.4
commit: 514910667b0d6e375569a48fb7cef11411d30fbd commit: 239960683b6c7f1347e1798b0e179a8a46628728
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: ce773b63c625448df8e128508b46e7e84d2e5cdb1f2b65a6a03f52a4e350b0bf sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
+99
View File
@@ -0,0 +1,99 @@
{
lib,
pkgs,
rustToolchain,
runtimeLibs,
suwayomiServer,
version,
versions,
src,
appIcon,
}:
pkgs.stdenv.mkDerivation {
pname = "moku";
inherit version src;
nativeBuildInputs = with pkgs; [
rustToolchain
nodejs_22
pnpm
pnpmConfigHook
pkg-config
wrapGAppsHook3
rustPlatform.cargoSetupHook
];
buildInputs = runtimeLibs;
pnpmDeps = pkgs.fetchPnpmDeps {
pname = "moku";
inherit version src;
fetcherVersion = 3;
hash = versions.frontend.pnpmHash;
};
cargoDeps = pkgs.rustPlatform.importCargoLock {
lockFile = ../src-tauri/Cargo.lock;
outputHashes = {
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
};
};
env = {
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
TAURI_SKIP_DEVSERVER_CHECK = "true";
cargoRoot = "src-tauri";
};
buildPhase = ''
export HOME=$(mktemp -d)
pnpm tauri:build
'';
installPhase = ''
install -Dm755 src-tauri/target/release/moku $out/bin/moku
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF2
[Desktop Entry]
Version=1.0
Type=Application
Name=Moku
Comment=Manga reader frontend for Suwayomi
Exec=$out/bin/moku
Icon=moku
Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF2
for size in 32x32 128x128 256x256 512x512; do
f="src-tauri/icons/$size.png"
[ -f "$f" ] && install -Dm644 "$f" \
"$out/share/icons/hicolor/$size/apps/moku.png"
done
for size in 128x128 256x256; do
f="src-tauri/icons/''${size}@2x.png"
[ -f "$f" ] && install -Dm644 "$f" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
install -Dm644 "${appIcon}" \
"$out/share/icons/hicolor/scalable/apps/moku.svg"
wrapProgram $out/bin/moku \
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
pkgs.gsettings-desktop-schemas
pkgs.gtk3
]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
meta.mainProgram = "moku";
}
+140
View File
@@ -0,0 +1,140 @@
{ pkgs, rustToolchain, version, versions }:
{
bump = pkgs.writeShellApplication {
name = "moku-bump";
runtimeInputs = with pkgs; [
gnused
coreutils
git
rustToolchain
nodejs_22
pnpm
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
(cd "$REPO/src-tauri" && cargo generate-lockfile)
cd "$REPO"
pnpm install --frozen-lockfile
pnpm build:static
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
"$REPO/src-tauri/Cargo.lock" \
-o "$REPO/packaging/cargo-sources.json"
echo "Bumped to v$VERSION commit, tag, push, then: nix run .#update -- $VERSION"
'';
};
update = pkgs.writeShellApplication {
name = "moku-update";
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
text = ''
REPO="$(git rev-parse --show-toplevel)"
VERSIONS="$REPO/nix/versions.nix"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
PKGBUILD="$REPO/PKGBUILD"
if [[ $# -ge 1 ]]; then
VERSION="$1"
else
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
fi
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
if [[ $# -ge 2 ]]; then
SUWA_VER="$2"
JAR_URL="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VER}/Suwayomi-Server-v${SUWA_VER}.jar"
SUWA_SHA_HEX=$(curl -fsSL "$JAR_URL" | sha256sum | awk '{print $1}')
SUWA_SHA_SRI=$(echo "$SUWA_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
sed -i "s/version = \"[^\"]*\"/version = \"$SUWA_VER\"/" "$VERSIONS"
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"$SUWA_SHA_SRI\"|" "$VERSIONS"
sed -i "s|Suwayomi-Server-preview/releases/download/v[^/]*/|Suwayomi-Server-preview/releases/download/v${SUWA_VER}/|" "$MANIFEST"
sed -i "s|Suwayomi-Server-v[0-9.]*\.jar|Suwayomi-Server-v${SUWA_VER}.jar|g" "$MANIFEST"
python3 - "$MANIFEST" "$SUWA_SHA_HEX" <<'PYEOF'
import re, sys
path, sha = sys.argv[1], sys.argv[2]
text = open(path).read()
updated, n = re.subn(
r'(dest-filename:\s*Suwayomi-Server\.jar\s*\n\s*sha256:\s*)[0-9a-f]+',
r'\g<1>' + sha, text)
if n == 0:
sys.exit("ERROR: could not find Suwayomi jar sha256 in manifest")
open(path, 'w').write(updated)
PYEOF
fi
echo "Done versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
'';
};
flatpak = pkgs.writeShellApplication {
name = "moku-flatpak";
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
text = ''
REPO="$(git rev-parse --show-toplevel)"
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$REPO/io.github.moku_project.Moku.yml"
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
rm -rf "$REPO/build-dir" "$REPO/repo"
echo "moku.flatpak created"
'';
};
tunnel = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
}
+48
View File
@@ -0,0 +1,48 @@
{
lib,
stdenvNoCC,
fetchurl,
makeWrapper,
jdk21_headless,
versions,
}:
let
jdk = jdk21_headless;
ver = versions.suwayomi;
in
stdenvNoCC.mkDerivation {
pname = "suwayomi-server";
version = ver.version;
src = fetchurl {
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
hash = ver.hash;
};
nativeBuildInputs = [ makeWrapper ];
dontUnpack = true;
buildPhase = ''
runHook preBuild
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
runHook postBuild
'';
meta = {
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
license = lib.licenses.mpl20;
platforms = jdk.meta.platforms;
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
mainProgram = "suwayomi-server";
};
}
+21
View File
@@ -0,0 +1,21 @@
{
moku = "0.9.4";
suwayomi = {
version = "2.1.2087";
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
};
frontend = {
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
distHashSri = "sha256-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
};
gitDeps = {
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
};
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
tarballHash = "";
}
+40 -23
View File
@@ -1,31 +1,48 @@
{ {
"name": "moku", "name": "moku",
"version": "0.5.0", "private": true,
"version": "0.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite dev",
"build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "prepare": "svelte-kit sync || echo ''",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json" "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
}, "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dependencies": { "build:static": "MOKU_TARGET=static vite build",
"@tauri-apps/api": "^2.0.0", "build:node": "MOKU_TARGET=node vite build",
"@tauri-apps/plugin-http": "^2.5.8", "build:android": "MOKU_TARGET=static vite build",
"@tauri-apps/plugin-os": "^2.3.2", "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"@tauri-apps/plugin-shell": "^2.3.5", "tauri:build": "tauri build"
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
"tauri-plugin-drpc": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/adapter-node": "^5.5.4",
"@tauri-apps/cli": "^2.0.0", "@sveltejs/adapter-static": "^3.0.10",
"svelte": "^5.0.0", "@sveltejs/kit": "^2.62.0",
"svelte-check": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"typescript": "^5.0.0", "@tauri-apps/cli": "^2.11.2",
"vite": "^5.0.0" "svelte": "^5.56.1",
"svelte-check": "^4.5.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
"@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.4.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/preferences": "^8.0.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
} }
} }
File diff suppressed because it is too large Load Diff
+1054 -878
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
+379 -669
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moku" name = "moku"
version = "0.9.1" version = "0.9.4"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -15,12 +15,13 @@ path = "src/main.rs"
tauri-build = { version = "2.0", features = [] } tauri-build = { version = "2.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0", features = [] } tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-os = "2.3.2" tauri-plugin-os = "2.3.2"
tauri-plugin-store = "2"
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" } tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -31,6 +32,12 @@ urlencoding = "2"
tokio = { version = "1", features = ["rt-multi-thread"] } tokio = { version = "1", features = ["rt-multi-thread"] }
reqwest = { version = "0.12", features = ["blocking"] } reqwest = { version = "0.12", features = ["blocking"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [
"Security_Credentials_UI",
"Win32_UI_WindowsAndMessaging",
] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Moku — Suwayomi launcher for Linux AppImage/deb. # — Suwayomi launcher for Linux AppImage/deb.
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for # Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory. # "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
set -e set -e
@@ -53,7 +53,7 @@ if [ ! -f "$JAR" ]; then
fi fi
# ── Data directory ───────────────────────────────────────────────────────────── # ── Data directory ─────────────────────────────────────────────────────────────
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
# ── Seed server.conf on first run ────────────────────────────────────────────── # ── Seed server.conf on first run ──────────────────────────────────────────────
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF' cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1" server.ip = "127.0.0.1"
server.port = 4567 server.port = 4567
server.webUIEnabled = false server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false server.systemTrayEnabled = false
server.webUIInterface = "browser" server.webUIInterface = "browser"
server.webUIFlavor = "WebUI" server.webUIFlavor = "WebUI"
server.webUIChannel = "stable" server.webUIChannel = "PREVIEW"
server.electronPath = "" server.electronPath = ""
server.debugLogsEnabled = false server.debugLogsEnabled = false
server.downloadAsCbz = true server.downloadAsCbz = true
@@ -79,13 +79,13 @@ fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ──────────────────── # ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
sed -i \ sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \ -e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \ -e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \ -e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf" "$DATA_DIR/server.conf"
# Append keys if absent (e.g. user-managed conf missing them) # Append keys if absent (e.g. user-managed conf missing them)
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf" grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+13 -3
View File
@@ -2,9 +2,15 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default permissions for Moku", "description": "Default permissions for Moku",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:tray:default",
"core:app:allow-default-window-icon",
"core:window:allow-hide",
"core:window:allow-show",
"shell:allow-open", "shell:allow-open",
"shell:allow-kill", "shell:allow-kill",
"shell:allow-spawn", "shell:allow-spawn",
@@ -27,14 +33,18 @@
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-scale-factor", "core:window:allow-scale-factor",
"process:default", "process:default",
"process:allow-exit",
"process:allow-restart", "process:allow-restart",
"http:default", "http:default",
"http:allow-fetch", "http:allow-fetch",
"store:default",
"discord-rpc:default", "discord-rpc:default",
"discord-rpc:allow-connect", "discord-rpc:allow-connect",
"discord-rpc:allow-disconnect", "discord-rpc:allow-disconnect",
"discord-rpc:allow-set-activity", "discord-rpc:allow-set-activity",
"discord-rpc:allow-clear-activity", "discord-rpc:allow-clear-activity",
"discord-rpc:allow-is-running" "discord-rpc:allow-is-running",
"dialog:default",
"dialog:allow-open"
] ]
} }
+100
View File
@@ -0,0 +1,100 @@
use std::path::PathBuf;
use tauri::Manager;
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[tauri::command]
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
use tauri_plugin_dialog::DialogExt;
let filename = format!("moku-backup-{}.zip", unix_now());
let path = app
.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.add_filter("Moku Backup", &["zip"])
.blocking_save_file()
.ok_or("Cancelled")?;
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app
.dialog()
.file()
.set_title("Open Moku app data backup")
.add_filter("Moku Backup", &["zip"])
.blocking_pick_file()
.ok_or("Cancelled")?;
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
let dir = backup_dir(&app);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("auto-moku-backup-")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
let dir = backup_dir(&app);
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
#[tauri::command]
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
let base = app
.path()
.app_local_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
names
.into_iter()
.map(|name| {
let content = std::fs::read_to_string(base.join(&name))
.unwrap_or_else(|_| "{}".to_string());
(name, content)
})
.collect()
}
+103
View File
@@ -0,0 +1,103 @@
#[cfg(target_os = "windows")]
mod windows_hello {
use windows::{
core::HSTRING,
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
Win32::UI::WindowsAndMessaging::{
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
},
};
fn to_wide(s: &str) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
std::ffi::OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn try_focus_hello_dialog() -> bool {
let cls = to_wide("Credential Dialog Xaml Host");
unsafe {
let Ok(hwnd) = FindWindowW(
windows::core::PCWSTR(cls.as_ptr()),
windows::core::PCWSTR::null(),
) else {
return false;
};
if IsIconic(hwnd).as_bool() {
let _ = ShowWindow(hwnd, SW_RESTORE);
}
let _ = BringWindowToTop(hwnd);
let _ = SetForegroundWindow(hwnd);
true
}
}
fn nudge_focus(retries: u32, delay_ms: u64) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
for _ in 0..retries {
if try_focus_hello_dialog() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
});
}
pub fn authenticate(reason: &str) -> Result<(), String> {
let reason = reason.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
nudge_focus(5, 250);
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
});
let _ = tx.send(outcome);
});
let result = rx
.recv()
.map_err(|e| format!("internalError:{e:?}"))?
.map_err(|e| format!("internalError:{e:?}"))?;
match result {
UserConsentVerificationResult::Verified => Ok(()),
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
_ => Err("authenticationFailed".into()),
}
}
pub fn is_available() -> bool {
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
UserConsentVerifier::CheckAvailabilityAsync()
.and_then(|op| op.get())
.map(|a| a == UserConsentVerifierAvailability::Available)
.unwrap_or(false)
}
}
#[tauri::command]
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
return windows_hello::authenticate(&_reason);
#[cfg(not(target_os = "windows"))]
Err("notSupported".into())
}
#[tauri::command]
pub fn windows_hello_available() -> bool {
#[cfg(target_os = "windows")]
return windows_hello::is_available();
#[cfg(not(target_os = "windows"))]
false
}
+6
View File
@@ -0,0 +1,6 @@
pub mod backup;
pub mod biometric;
pub mod server;
pub mod storage;
pub mod system;
pub mod updater;
+97
View File
@@ -0,0 +1,97 @@
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
use crate::ServerState;
use tauri::Manager;
#[tauri::command]
pub fn spawn_server(
binary: String,
binary_args: Option<String>,
web_ui_enabled: bool,
app: tauri::AppHandle,
) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let binary_args = binary_args.unwrap_or_default();
server::do_log(
&mut log,
&format!(
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
binary, binary_args, web_ui_enabled, data_dir
),
);
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
let mut invocation =
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
if !binary_args.trim().is_empty() {
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
let mut merged = extra;
merged.extend(invocation.args);
invocation.args = merged;
}
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation
.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
server::do_log(
&mut log,
&format!(
"[spawn_server] bin={:?} args={:?} cwd={:?}",
invocation.bin, invocation.args, working_dir
),
);
use tauri_plugin_shell::ShellExt;
let cmd = app
.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => {
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
server::kill_tachidesk(&app);
Ok(())
}
+178
View File
@@ -0,0 +1,178 @@
use serde::Serialize;
use std::path::PathBuf;
use sysinfo::Disks;
use tauri::Emitter;
use tauri_plugin_store::StoreExt;
use walkdir::WalkDir;
use crate::server::resolve::suwayomi_data_dir;
// ── Key-value store (used by the frontend via platformService) ────────────────
#[tauri::command]
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
let store = app
.store(format!("{}.json", key))
.map_err(|e| e.to_string())?;
let value = store.get(&key);
Ok(value.map(|v| v.to_string()))
}
#[tauri::command]
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
let store = app
.store(format!("{}.json", key))
.map_err(|e| e.to_string())?;
let parsed: serde_json::Value =
serde_json::from_str(&value).map_err(|e| e.to_string())?;
store.set(key, parsed);
store.save().map_err(|e| e.to_string())
}
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
#[tauri::command]
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
let store = app
.store("credentials.json")
.map_err(|e| e.to_string())?;
if value.is_empty() {
store.delete(&key);
} else {
store.set(&key, serde_json::Value::String(value));
}
store.save().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
let store = app
.store("credentials.json")
.map_err(|e| e.to_string())?;
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
}
// ── Disk / downloads storage ─────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StorageInfo {
pub manga_bytes: u64,
pub total_bytes: u64,
pub free_bytes: u64,
pub path: String,
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path.trim());
}
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
pub fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
pub fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
pub fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn migrate_downloads(
app: tauri::AppHandle,
src: String,
dst: String,
) -> Result<(), String> {
use std::fs;
let src_path = PathBuf::from(src.trim());
let dst_path = PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit(
"migrate_progress",
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
);
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry
.path()
.strip_prefix(&src_path)
.map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit(
"migrate_progress",
serde_json::json!({
"done": done, "total": total, "current": rel.to_string_lossy()
}),
);
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
+192
View File
@@ -0,0 +1,192 @@
#[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc;
use std::path::PathBuf;
use tauri::Manager;
#[tauri::command]
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
#[tauri::command]
pub fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[tauri::command]
pub fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
#[tauri::command]
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
#[cfg(target_os = "windows")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
#[cfg(target_os = "macos")]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let dialog = app
.dialog()
.file()
.set_title("Choose Server Binary")
.add_filter("Executable or JAR", &["jar", "sh"]);
dialog.blocking_pick_file().map(|p| p.to_string())
}
#[tauri::command]
pub fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
fn remove_dir_best_effort(path: &std::path::Path) {
if path.is_file() {
if let Err(e) = std::fs::remove_file(path) {
if e.raw_os_error() == Some(32) {
return;
}
}
} else if path.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_dir_best_effort(&entry.path());
}
}
let _ = std::fs::remove_dir(path);
}
}
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
let locked = if path.is_file() {
std::fs::OpenOptions::new().write(true).open(path).is_err()
} else if path.is_dir() {
std::fs::read_dir(path).is_err()
} else {
return true;
};
if !locked {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[tauri::command]
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
let window = app.get_webview_window("main").ok_or("no main window")?;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
window
.with_webview(move |_wv| {
let _ = tx.send(Ok(()));
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())??;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() {
wait_until_deletable(&cache_dir, 3);
remove_dir_best_effort(&cache_dir);
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir();
for dir in &["cache/kcef", "logs"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
}
}
for dir in &["downloads/thumbnails"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
let _ = std::fs::create_dir_all(&p);
}
}
Ok(())
}
#[tauri::command]
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app);
let data_dir = suwayomi_data_dir();
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.exists() {
wait_until_deletable(&p, 10);
}
}
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.is_dir() {
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
} else if p.exists() {
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
}
}
Ok(())
}
+150
View File
@@ -0,0 +1,150 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
#[tauri::command]
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
let releases: Vec<GhRelease> =
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
Ok(releases
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
})
.collect())
}
#[tauri::command]
#[allow(unused_variables)]
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use std::io::Write;
use tauri::Emitter;
use tauri_plugin_http::reqwest;
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Deserialize)]
struct Release {
assets: Vec<Asset>,
}
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(format!(
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
tag
))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!(
"GitHub API returned {} for tag {}",
resp.status(),
tag
));
}
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let asset = release
.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 {
Some(asset.size)
} else {
None
};
let mut resp = client
.get(&asset.browser_download_url)
.send()
.await
.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
+130 -804
View File
@@ -1,810 +1,84 @@
use std::path::PathBuf; mod commands;
mod server;
use std::sync::Mutex; use std::sync::Mutex;
use std::io::Write; use std::io::{Read, Write};
use sysinfo::Disks; use std::net::{TcpListener, TcpStream};
use serde::Serialize; use tauri::{
use tauri::{Manager, WindowEvent}; menu::{Menu, MenuItem, PredefinedMenuItem},
#[cfg(target_os = "windows")] tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
use tauri::Emitter; Manager, WindowEvent,
use tauri_plugin_shell::{ShellExt, process::CommandChild}; };
use walkdir::WalkDir; use tauri_plugin_shell::process::CommandChild;
struct ServerState(Mutex<Option<CommandChild>>); pub struct ServerState(pub Mutex<Option<CommandChild>>);
#[derive(Serialize)] const IPC_PORT: u16 = 47823;
pub struct StorageInfo { const HANDSHAKE: &[u8] = b"MOKU:1\n";
manga_bytes: u64, const FOCUS_CMD: &[u8] = b"focus\n";
total_bytes: u64,
free_bytes: u64, fn do_quit(app: &tauri::AppHandle) {
path: String, server::kill_tachidesk(app);
app.exit(0);
} }
#[derive(Serialize, Debug)] fn start_instance_listener(app: tauri::AppHandle) {
#[serde(tag = "kind", content = "message")] std::thread::spawn(move || {
pub enum SpawnError { let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
NotConfigured(String),
SpawnFailed(String),
}
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
#[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path.trim());
}
suwayomi_data_dir().join("downloads")
}
#[tauri::command]
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
fn get_default_downloads_path() -> String {
resolve_downloads_path("").to_string_lossy().into_owned()
}
#[tauri::command]
fn check_path_exists(path: String) -> bool {
std::path::Path::new(path.trim()).is_dir()
}
#[tauri::command]
fn create_directory(path: String) -> Result<(), String> {
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
use tauri::Emitter;
use std::fs;
let src_path = std::path::PathBuf::from(src.trim());
let dst_path = std::path::PathBuf::from(dst.trim());
if !src_path.is_dir() {
return Ok(());
}
let total: u64 = WalkDir::new(&src_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count() as u64;
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
let mut done: u64 = 0;
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
let target = dst_path.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
done += 1;
let _ = app.emit("migrate_progress", serde_json::json!({
"done": done, "total": total, "current": rel.to_string_lossy()
}));
}
}
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running { break; }
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
}
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return; return;
};
for stream in listener.incoming().flatten() {
handle_ipc_connection(stream, &app);
} }
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) { });
eprintln!("Could not write server.conf: {e}"); }
}
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
let mut buf = [0u8; 32];
let Ok(n) = stream.read(&mut buf) else { return };
let msg = &buf[..n];
if !msg.starts_with(HANDSHAKE) {
return; return;
} }
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return }; let cmd = &msg[HANDSHAKE.len()..];
if cmd.starts_with(b"focus") {
let patched = patch_conf_key( let _ = stream.write_all(b"ok\n");
patch_conf_key( if let Some(win) = app.get_webview_window("main") {
patch_conf_key(contents, "server.webUIEnabled", "false"), let _ = win.show();
"server.initialOpenInBrowserEnabled", "false", let _ = win.unminimize();
), let _ = win.set_focus();
"server.systemTrayEnabled", "false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') { out.push('\n'); }
out.push_str(&replacement);
out.push('\n');
out
}
fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("moku\\tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("io.github.moku_project.Moku.app/tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("moku/tachidesk")
}
}
struct ServerInvocation {
bin: String,
args: Vec<String>,
working_dir: Option<PathBuf>,
}
#[cfg(not(target_os = "macos"))]
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
#[cfg(target_os = "windows")]
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
#[cfg(not(target_os = "windows"))]
let java = bundle_dir.join("jre").join("bin").join("java");
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary = {:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
if path.exists() {
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: vec![],
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
} }
} }
}
#[cfg(not(target_os = "macos"))] fn signal_existing_instance() -> bool {
let resource_dir = { let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
let raw = app.path().resource_dir().unwrap_or_default(); return false;
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
stripped
}; };
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
#[cfg(not(target_os = "macos"))] let mut msg = Vec::new();
{ msg.extend_from_slice(HANDSHAKE);
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); msg.extend_from_slice(FOCUS_CMD);
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists())); if stream.write_all(&msg).is_err() {
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists())); return false;
match find_java_in_bundle(&bundle_dir, log) {
Some(java) if jar.exists() => {
do_log(log, "[resolve] using bundled JRE");
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
}
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
} }
#[cfg(not(target_os = "macos"))] let mut resp = [0u8; 4];
{ matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(resource_dir.clone()),
});
}
}
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.and_then(|e| e.ok())
.map(|e| e.path())
});
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
});
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let contents_dir = resource_dir
.parent()
.unwrap_or(&resource_dir)
.to_path_buf();
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
const NATIVE_NAMES: &[&str] = &[
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
];
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None;
'outer: for depth in 0u8..=8 {
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
.min_depth(depth as usize)
.max_depth(depth as usize)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
for dir in &entries {
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
for name in NATIVE_NAMES {
let p = dir.join(name);
if p.exists() {
do_log(log, &format!("[resolve] found native binary: {:?}", p));
found_binary = Some(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(dir.clone()),
});
break 'outer;
}
}
if found_java.is_none() {
let java_exe = dir.join("bin").join("java");
if java_exe.exists() {
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
let mut search = dir.as_path();
'jar: for _ in 0..5 {
if let Ok(rd) = std::fs::read_dir(search) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
let bin_sibling = search.join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
for entry in rd.filter_map(|e| e.ok()) {
if entry.file_name().to_string_lossy().ends_with(".jar") {
let jar = entry.path();
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
found_java = Some((java_exe.clone(), jar));
break 'jar;
}
}
}
match search.parent() {
Some(p) => search = p,
None => break,
}
}
}
}
}
}
if let Some(inv) = found_binary {
return Ok(inv);
}
if let Some((java, jar)) = found_java {
let working_dir = jar.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
});
}
do_log(log, "[resolve] macOS scan found nothing in bundle");
}
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
#[cfg(not(target_os = "windows"))]
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
if found {
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
#[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
seed_server_conf(&data_dir);
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
e
})?;
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
}
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
let cmd = app.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
match cmd.spawn() {
Ok((_rx, child)) => {
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app);
Ok(())
}
#[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases.into_iter().map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
}).collect())
}
#[tauri::command]
#[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use tauri_plugin_http::reqwest;
use std::io::Write;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let url = format!("https://api.github.com/repos/moku-project/Moku/releases/tags/{}", tag);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
}
#[derive(serde::Deserialize)]
struct Asset { name: String, browser_download_url: String, size: u64 }
#[derive(serde::Deserialize)]
struct Release { assets: Vec<Asset> }
let body = resp.text().await.map_err(|e| e.to_string())?;
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let asset = release.assets
.into_iter()
.find(|a| a.name.ends_with("_x64-setup.exe"))
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
let total = if asset.size > 0 { Some(asset.size) } else { None };
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
let tmp_path = std::env::temp_dir().join(&asset.name);
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
}
drop(file);
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new(&tmp_path)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
let _ = app.emit("update-launching", ());
Ok(())
}
}
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
#[tauri::command]
fn open_path(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let p = strip_unc(std::path::PathBuf::from(path.trim()));
std::process::Command::new("explorer")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let p = std::path::Path::new(path.trim());
std::process::Command::new("xdg-open")
.arg(p)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
app.dialog()
.file()
.set_title("Choose Downloads Folder")
.blocking_pick_folder()
.map(|p| p.to_string())
}
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
app.path().app_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("backups")
}
#[tauri::command]
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("moku-backup-{}.json", now);
let path = app.dialog()
.file()
.set_title("Save Moku app data backup")
.set_file_name(&filename)
.blocking_save_file()
.ok_or("Cancelled")?;
let dest = PathBuf::from(path.to_string());
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().into_owned())
}
#[tauri::command]
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog()
.file()
.set_title("Open Moku app data backup")
.blocking_pick_file()
.ok_or("Cancelled")?;
let src = PathBuf::from(path.to_string());
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
Ok(contents)
}
#[tauri::command]
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
let backup_dir = moku_backup_dir(&app);
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
.collect();
entries.sort_by_key(|e| e.file_name());
for old in entries.iter().take(entries.len().saturating_sub(5)) {
let _ = std::fs::remove_file(old.path());
}
Ok(())
}
#[tauri::command]
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
moku_backup_dir(&app).to_string_lossy().into_owned()
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
if signal_existing_instance() {
std::process::exit(0);
}
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_discord_rpc::init()) .plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
@@ -813,30 +87,82 @@ pub fn run() {
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_storage_info, commands::storage::get_storage_info,
get_default_downloads_path, commands::storage::get_default_downloads_path,
check_path_exists, commands::storage::check_path_exists,
create_directory, commands::storage::create_directory,
migrate_downloads, commands::storage::migrate_downloads,
spawn_server, commands::server::spawn_server,
kill_server, commands::server::kill_server,
get_platform_ui_scale, commands::system::get_platform_ui_scale,
list_releases, commands::system::restart_app,
download_and_install_update, commands::system::exit_app,
restart_app, commands::system::clear_moku_cache,
open_path, commands::system::clear_suwayomi_cache,
pick_downloads_folder, commands::system::reset_suwayomi_data,
export_app_data, commands::system::open_path,
import_app_data, commands::system::pick_downloads_folder,
auto_backup_app_data, commands::system::pick_server_binary,
get_auto_backup_dir, commands::backup::export_app_data,
commands::backup::import_app_data,
commands::backup::auto_backup_app_data,
commands::backup::get_auto_backup_dir,
commands::backup::read_store_files,
commands::storage::load_store,
commands::storage::save_store,
commands::storage::store_credential,
commands::storage::get_credential,
commands::updater::list_releases,
commands::updater::download_and_install_update,
commands::biometric::windows_hello_authenticate,
commands::biometric::windows_hello_available,
]) ])
.setup(|_app| Ok(())) .setup(|app| {
start_instance_listener(app.handle().clone());
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("Moku")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => do_quit(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::Destroyed = event { if let WindowEvent::Destroyed = event {
kill_tachidesk(window.app_handle()); server::kill_tachidesk(window.app_handle());
} }
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running moku"); .expect("error while running moku")
} }
+1 -1
View File
@@ -2,4 +2,4 @@
fn main() { fn main() {
moku_lib::run(); moku_lib::run();
} }
+82
View File
@@ -0,0 +1,82 @@
use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "preview"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
let initial = patch_conf_key(
DEFAULT_SERVER_CONF.to_string(),
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
);
if let Err(e) = std::fs::write(&conf_path, initial) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
return;
};
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(
contents,
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
),
"server.initialOpenInBrowserEnabled",
"false",
),
"server.systemTrayEnabled",
"false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&replacement);
out.push('\n');
out
}
+53
View File
@@ -0,0 +1,53 @@
pub mod conf;
pub mod resolve;
use std::io::Write;
use tauri::Manager;
use crate::ServerState;
pub use resolve::SpawnError;
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
eprintln!("{}", msg);
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
pub fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.args(["-f", "tachidesk"])
.status();
}
+240
View File
@@ -0,0 +1,240 @@
use crate::server::do_log;
use serde::Serialize;
use std::path::PathBuf;
use tauri::Manager;
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
pub struct ServerInvocation {
pub bin: String,
pub args: Vec<String>,
pub working_dir: Option<PathBuf>,
}
pub fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("Tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("Tachidesk")
}
}
pub fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn java_bin_name() -> &'static str {
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
}
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
if java.exists() { Some(java) } else { None }
}
fn data_root_args() -> Vec<String> {
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
}
fn jar_data_root_flag() -> String {
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
}
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
ServerInvocation {
bin: java.to_string_lossy().into_owned(),
args: vec![
jar_data_root_flag(),
"-jar".to_string(),
jar.to_string_lossy().into_owned(),
],
working_dir: Some(working_dir),
}
}
pub fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary={:?}", binary));
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
if path.exists() {
let working_dir = path.parent().map(|p| p.to_path_buf());
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir,
});
}
do_log(log, "[resolve] user path not found, falling through");
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
for name in &["tachidesk-server", "suwayomi-launcher"] {
let p = bin_dir.join(name);
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(bin_dir.to_path_buf()),
});
}
}
}
}
#[cfg(not(target_os = "macos"))]
{
let resource_dir = {
let raw = app.path().resource_dir().unwrap_or_default();
let stripped = strip_unc(raw);
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
stripped
};
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
if jar.exists() {
do_log(log, "[resolve] using bundled JRE + jar");
return Ok(jar_invocation(java, jar, bundle_dir));
}
}
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: data_root_args(),
working_dir: Some(resource_dir.clone()),
});
}
}
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
let jar = std::fs::read_dir(&resource_dir)
.ok()
.and_then(|mut rd| {
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
.and_then(|e| e.ok())
.map(|e| e.path())
});
if let Some(jar_path) = jar {
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
return Ok(jar_invocation(java, jar_path, resource_dir));
}
}
}
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let bundle_dir = resource_dir.join("suwayomi-bundle");
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
let java = bundle_dir.join("jre").join("bin").join("java");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
if java.exists() && jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
return Ok(jar_invocation(java, jar, bundle_dir));
}
if launcher_sh.exists() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
return Ok(ServerInvocation {
bin: launcher_sh.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bundle_dir),
});
}
if java.exists() && launcher_jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
}
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
}
for name in &["suwayomi-server", "tachidesk-server"] {
#[cfg(target_os = "windows")]
let resolved = std::process::Command::new("where")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
#[cfg(not(target_os = "windows"))]
let resolved = std::process::Command::new("which")
.arg(name)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
if let Some(bin_path) = resolved {
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
return Ok(ServerInvocation {
bin: bin_path,
args: vec![],
working_dir: None,
});
}
}
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
+3 -2
View File
@@ -1,11 +1,12 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Moku", "productName": "Moku",
"version": "0.9.1", "version": "0.9.4",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm build:static"
}, },
"app": { "app": {
"windows": [ "windows": [
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"build": { "build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev" "beforeDevCommand": "pnpm dev"
}, },
"app": { "app": {
@@ -13,4 +12,4 @@
"bundle": { "bundle": {
"externalBin": [] "externalBin": []
} }
} }
-165
View File
@@ -1,165 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { store, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { applyTheme } from "@core/theme";
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
import { checkForUpdateSilently } from "@core/updater";
import Layout from "@shared/chrome/Layout.svelte";
import Reader from "@features/reader/components/Reader.svelte";
import Settings from "@features/settings/components/Settings.svelte";
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
import TitleBar from "@shared/chrome/TitleBar.svelte";
import Toaster from "@shared/chrome/Toaster.svelte";
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
import MangaPreview from "@shared/manga/MangaPreview.svelte";
import AuthGate from "@shared/chrome/AuthGate.svelte";
const win = getCurrentWindow();
void platform();
let appReady = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null;
themeEditorOpen = true;
}
function closeThemeEditor() {
themeEditorOpen = false;
themeEditorEditId = null;
}
$effect(() => { void store.settings.theme; applyTheme(); });
$effect(() => { void store.settings.uiZoom; applyZoom(); });
$effect(() => mountZoomKey());
$effect(() => {
if (!appReady) return;
return mountIdleDetection(
() => { idle = true; },
() => { if (idle) idle = false; },
);
});
$effect(() => {
if (!appReady) return;
const timer = setTimeout(checkForUpdateSilently, 5_000);
return () => clearTimeout(timer);
});
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
} else {
clearReading();
destroyRpc();
}
});
$effect(() => {
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
$effect(() => {
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => { devSplash = true; };
applyZoom();
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
});
const unlistenScale = await win.onScaleChanged(async () => {
applyZoom();
});
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
}
startProbe();
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress",
e => setActiveDownloads(e.payload),
);
await downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => {
stopProbe();
clearInterval(dlInterval);
unlistenResize();
unlistenScale();
unlistenDownload();
destroyRpc();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
delete (window as any).__mokuShowSplash;
};
});
</script>
{#if devSplash}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
failed={boot.failed} notConfigured={boot.notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => { appReady = true; }}
onRetry={retryBoot}
onBypass={() => bypassBoot(() => { appReady = true; })} />
{:else if boot.unsupportedMode || boot.loginRequired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { appReady = true; }} />
{:else}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => { idle = false; }} />
{/if}
<div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar />{/if}
<div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
{#if themeEditorOpen}
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
{/if}
<MangaPreview />
<Toaster />
</div>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
</style>
-75
View File
@@ -1,75 +0,0 @@
import { store } from "@store/state.svelte";
import { fetchAuthenticated } from "../core/auth";
const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
}
export function plainThumbUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
}
export const thumbUrl = plainThumbUrl;
interface GQLResponse<T> {
data: T;
errors?: { message: string }[];
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
async function fetchWithRetry(
url: string,
init: RequestInit,
signal?: AbortSignal,
retries = 3,
delayMs = 300,
): Promise<Response> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetchAuthenticated(url, init, signal);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e: any) {
if (e?.authRequired) throw e;
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (i === retries - 1) throw e;
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
}
throw new Error("unreachable");
}
export async function gql<T>(
query: string,
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const res = await fetchWithRetry(
`${getServerUrl()}/api/graphql`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
signal,
);
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json();
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
-11
View File
@@ -1,11 +0,0 @@
export * from "./client";
export * from "./queries/manga";
export * from "./queries/chapters";
export * from "./queries/downloads";
export * from "./queries/extensions";
export * from "./queries/tracking";
export * from "./mutations/manga";
export * from "./mutations/chapters";
export * from "./mutations/downloads";
export * from "./mutations/extensions";
export * from "./mutations/tracking";
-48
View File
@@ -1,48 +0,0 @@
export const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator
}
}
}
`;
export const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`;
export const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`;
export const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`;
export const UPDATE_CHAPTERS_PROGRESS = `
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
chapters { id isRead isBookmarked lastPageRead }
}
}
`;
export const DELETE_DOWNLOADED_CHAPTERS = `
mutation DeleteDownloadedChapters($ids: [Int!]!) {
deleteDownloadedChapters(input: { ids: $ids }) {
chapters { id isDownloaded }
}
}
`;
-89
View File
@@ -1,89 +0,0 @@
export const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`;
export const INSTALL_EXTERNAL_EXTENSION = `
mutation InstallExternalExtension($url: String!) {
installExternalExtension(input: { extensionUrl: $url }) {
extension { apkName pkgName name isInstalled }
}
}
`;
export const SET_EXTENSION_REPOS = `
mutation SetExtensionRepos($repos: [String!]!) {
setSettings(input: { settings: { extensionRepos: $repos } }) {
settings { extensionRepos }
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings { authMode authUsername }
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
}
`;
-5
View File
@@ -1,5 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
-91
View File
@@ -1,91 +0,0 @@
export const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`;
export const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`;
export const UPDATE_MANGAS = `
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
mangas { id inLibrary }
}
}
`;
export const UPDATE_MANGA_CATEGORIES = `
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
manga { id }
}
}
`;
export const CREATE_CATEGORY = `
mutation CreateCategory($name: String!) {
createCategory(input: { name: $name }) {
category { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_CATEGORY = `
mutation UpdateCategory($id: Int!, $name: String) {
updateCategory(input: { id: $id, patch: { name: $name } }) {
category { id name order }
}
}
`;
export const DELETE_CATEGORY = `
mutation DeleteCategory($id: Int!) {
deleteCategory(input: { categoryId: $id }) {
category { id }
}
}
`;
export const UPDATE_CATEGORY_ORDER = `
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
updateCategoryOrder(input: { id: $id, position: $position }) {
categories { id name order default includeInUpdate includeInDownload }
}
}
`;
export const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus {
jobsInfo { isRunning finishedJobs totalJobs }
}
}
}
`;
export const CREATE_BACKUP = `
mutation CreateBackup {
createBackup(input: {}) { url }
}
`;
export const RESTORE_BACKUP = `
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: { backup: $backup }) {
id
status { mangaProgress state totalManga }
}
}
`;
-450
View File
@@ -1,450 +0,0 @@
# Mutations
## Manga (`mutations/manga.ts`)
### `FETCH_MANGA`
Fetches and refreshes manga metadata from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `UPDATE_MANGA`
Updates a single manga's library membership.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGAS`
Bulk-updates library membership for multiple manga.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Manga IDs |
| `inLibrary` | `Boolean` | Add/remove from library |
---
### `UPDATE_MANGA_CATEGORIES`
Adds or removes a manga from categories.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `addTo` | `[Int!]!` | Category IDs to add to |
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
---
### `CREATE_CATEGORY`
Creates a new manga category.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `name` | `String!` | Category name |
---
### `UPDATE_CATEGORY`
Updates a category's name.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `name` | `String` | New name |
---
### `DELETE_CATEGORY`
Deletes a category by ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
---
### `UPDATE_CATEGORY_ORDER`
Moves a category to a new position.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Category ID |
| `position` | `Int!` | New position index |
---
### `UPDATE_LIBRARY`
Triggers a library-wide metadata refresh and returns job status.
**Variables:** none
---
### `CREATE_BACKUP`
Creates a backup and returns its download URL.
**Variables:** none
---
### `RESTORE_BACKUP`
Restores a backup from an uploaded file and returns restore job status.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`mutations/chapters.ts`)
### `FETCH_CHAPTERS`
Fetches/refreshes the chapter list for a manga from its source.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `FETCH_CHAPTER_PAGES`
Fetches the page URLs for a specific chapter.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `MARK_CHAPTER_READ`
Marks a single chapter as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Chapter ID |
| `isRead` | `Boolean!` | Read state |
---
### `MARK_CHAPTERS_READ`
Bulk-marks multiple chapters as read or unread.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean!` | Read state |
---
### `UPDATE_CHAPTERS_PROGRESS`
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
| `isRead` | `Boolean` | Read state |
| `isBookmarked` | `Boolean` | Bookmark state |
| `lastPageRead` | `Int` | Last page index read |
---
### `DELETE_DOWNLOADED_CHAPTERS`
Deletes downloaded chapter files for the given chapter IDs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `ids` | `[Int!]!` | Chapter IDs |
---
## Downloads (`mutations/downloads.ts`)
### `ENQUEUE_DOWNLOAD`
Adds a single chapter to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `ENQUEUE_CHAPTERS_DOWNLOAD`
Adds multiple chapters to the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterIds` | `[Int!]!` | Chapter IDs |
---
### `DEQUEUE_DOWNLOAD`
Removes a chapter from the download queue.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `chapterId` | `Int!` | Chapter ID |
---
### `START_DOWNLOADER`
Starts the downloader and returns the current queue state.
**Variables:** none
---
### `STOP_DOWNLOADER`
Stops the downloader and returns the current queue state.
**Variables:** none
---
### `CLEAR_DOWNLOADER`
Clears all items from the download queue.
**Variables:** none
---
### `FETCH_SOURCE_MANGA`
Fetches manga from a source (browse/search), with pagination and optional filters.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `source` | `LongString!` | Source ID |
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
| `page` | `Int!` | Page number |
| `query` | `String` | Search query |
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
---
### `SET_DOWNLOADS_PATH`
Sets the downloads directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
### `SET_LOCAL_SOURCE_PATH`
Sets the local source directory path in settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `path` | `String!` | Filesystem path |
---
## Extensions (`mutations/extensions.ts`)
### `FETCH_EXTENSIONS`
Fetches the latest extension list from configured repos.
**Variables:** none
---
### `UPDATE_EXTENSION`
Installs, uninstalls, or updates an extension.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Extension package name |
| `install` | `Boolean` | Install the extension |
| `uninstall` | `Boolean` | Uninstall the extension |
| `update` | `Boolean` | Update the extension |
---
### `INSTALL_EXTERNAL_EXTENSION`
Installs an extension from an external APK URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `url` | `String!` | APK download URL |
---
### `SET_EXTENSION_REPOS`
Sets the list of extension repository URLs.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `repos` | `[String!]!` | Repository URLs |
---
### `SET_SERVER_AUTH`
Configures server authentication mode and credentials.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `authMode` | `AuthMode!` | Auth mode |
| `authUsername` | `String!` | Username |
| `authPassword` | `String!` | Password |
---
### `SET_SOCKS_PROXY`
Configures SOCKS proxy settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
| `socksProxyHost` | `String!` | Proxy host |
| `socksProxyPort` | `String!` | Proxy port |
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
| `socksProxyUsername` | `String!` | Proxy username |
| `socksProxyPassword` | `String!` | Proxy password |
---
### `SET_FLARESOLVERR`
Configures FlareSolverr integration settings.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
| `flareSolverrSessionName` | `String!` | Session name |
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
---
## Tracking (`mutations/tracking.ts`)
### `BIND_TRACK`
Binds a manga to a remote tracker entry.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
| `trackerId` | `Int!` | Tracker ID |
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
---
### `UPDATE_TRACK`
Updates tracking progress, status, score, and dates for a track record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
| `status` | `Int` | Reading status |
| `lastChapterRead` | `Float` | Last chapter read |
| `scoreString` | `String` | Score in tracker's format |
| `startDate` | `LongString` | Start date |
| `finishDate` | `LongString` | Finish date |
| `private` | `Boolean` | Mark as private |
---
### `UNBIND_TRACK`
Unbinds a manga from a tracker record.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `FETCH_TRACK`
Refreshes a track record from the remote tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `recordId` | `Int!` | Track record ID |
---
### `LOGIN_TRACKER_OAUTH`
Initiates OAuth login for a tracker using a callback URL.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `callbackUrl` | `String!` | OAuth callback URL |
---
### `LOGIN_TRACKER_CREDENTIALS`
Logs into a tracker using username and password.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `LOGOUT_TRACKER`
Logs out of a tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
---
### `LOGIN_USER`
Authenticates a user and returns access and refresh tokens.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `username` | `String!` | Username |
| `password` | `String!` | Password |
---
### `REFRESH_TOKEN`
Refreshes the current access token.
**Variables:** none
-80
View File
@@ -1,80 +0,0 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
`;
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { ${TRACK_RECORD_FRAGMENT} }
}
}
`;
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
}
}
}
`;
export const UNBIND_TRACK = `
mutation UnbindTrack($recordId: Int!) {
unbindTrack(input: { recordId: $recordId }) {
trackRecord { id }
}
}
`;
export const FETCH_TRACK = `
mutation FetchTrack($recordId: Int!) {
fetchTrack(input: { recordId: $recordId }) {
trackRecord {
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn authUrl }
}
}
`;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken refreshToken
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken {
refreshToken { accessToken }
}
`;
-22
View File
@@ -1,22 +0,0 @@
export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes {
mangaId
fetchedAt
manga { id title thumbnailUrl inLibrary }
}
}
}
`;
export const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead scanlator
}
}
}
`;
-14
View File
@@ -1,14 +0,0 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
-43
View File
@@ -1,43 +0,0 @@
export const GET_LOCAL_MANGA = `
query GetLocalManga {
mangas(condition: { sourceId: "0" }) {
nodes { id title thumbnailUrl inLibrary }
}
}
`;
export const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`;
export const GET_SOURCES = `
query GetSources {
sources {
nodes { id name lang displayName iconUrl isNsfw }
}
}
`;
export const GET_SETTINGS = `
query GetSettings {
settings { extensionRepos }
}
`;
export const GET_SERVER_SECURITY = `
query GetServerSecurity {
settings {
authMode authUsername
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
}
}
`;
-5
View File
@@ -1,5 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
-96
View File
@@ -1,96 +0,0 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount
description status author artist genre
source { id name displayName }
chapters { totalCount }
}
}
}
`;
export const GET_ALL_MANGA = `
query GetAllManga {
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount }
}
}
`;
export const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
`;
export const GET_CATEGORIES = `
query GetCategories {
categories {
nodes {
id name order default includeInUpdate includeInDownload
mangas {
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
}
}
}
}
`;
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
query GetDownloadedChaptersPages {
chapters(condition: { isDownloaded: true }) {
nodes { pageCount }
}
}
`;
export const GET_DOWNLOADS_PATH = `
query GetDownloadsPath {
settings { downloadsPath localSourcePath }
}
`;
export const LIBRARY_UPDATE_STATUS = `
query LibraryUpdateStatus {
libraryUpdateStatus {
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
mangaUpdates {
status
manga { id title thumbnailUrl unreadCount }
}
}
}
`;
export const GET_RESTORE_STATUS = `
query GetRestoreStatus($id: String!) {
restoreStatus(id: $id) { mangaProgress state totalManga }
}
`;
export const VALIDATE_BACKUP = `
query ValidateBackup($backup: Upload!) {
validateBackup(input: { backup: $backup }) {
missingSources { id name }
missingTrackers { name }
}
}
`;
export const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
-171
View File
@@ -1,171 +0,0 @@
# Queries
## Manga (`queries/manga.ts`)
### `GET_LIBRARY`
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
**Variables:** none
---
### `GET_ALL_MANGA`
Fetches all manga (library and non-library) with minimal fields.
**Variables:** none
---
### `GET_MANGA`
Fetches a single manga by ID with full metadata and source info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `Int!` | Manga ID |
---
### `GET_CATEGORIES`
Fetches all categories with their order, settings, and the manga assigned to each.
**Variables:** none
---
### `GET_DOWNLOADED_CHAPTERS_PAGES`
Fetches page counts for all downloaded chapters.
**Variables:** none
---
### `GET_DOWNLOADS_PATH`
Fetches the configured downloads path and local source path from settings.
**Variables:** none
---
### `LIBRARY_UPDATE_STATUS`
Fetches the current library update job status, including progress and any manga with new chapters.
**Variables:** none
---
### `GET_RESTORE_STATUS`
Fetches the status of a backup restore operation by its job ID.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `id` | `String!` | Restore job ID |
---
### `VALIDATE_BACKUP`
Validates a backup file and returns any missing sources or trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `backup` | `Upload!` | Backup file |
---
## Chapters (`queries/chapters.ts`)
### `GET_CHAPTERS`
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
## Downloads (`queries/downloads.ts`)
### `GET_DOWNLOAD_STATUS`
Fetches the current downloader state and full queue with chapter and manga info.
**Variables:** none
---
## Extensions (`queries/extensions.ts`)
### `GET_EXTENSIONS`
Fetches all extensions with install status, update availability, and metadata.
**Variables:** none
---
### `GET_SOURCES`
Fetches all available sources with language and NSFW flags.
**Variables:** none
---
### `GET_SETTINGS`
Fetches extension repository settings.
**Variables:** none
---
### `GET_SERVER_SECURITY`
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
**Variables:** none
---
## Tracking (`queries/tracking.ts`)
### `GET_TRACKERS`
Fetches all trackers with login status, supported scores, statuses, and auth info.
**Variables:** none
---
### `GET_MANGA_TRACK_RECORDS`
Fetches all tracking records for a specific manga across all trackers.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `mangaId` | `Int!` | Manga ID |
---
### `SEARCH_TRACKER`
Searches a tracker for manga by query string.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
| `query` | `String!` | Search query |
---
### `GET_ALL_TRACKER_RECORDS`
Fetches all trackers and their full track records, including associated manga info.
**Variables:** none
---
### `GET_TRACKER_RECORDS`
Fetches track records for a specific tracker.
**Variables:**
| Name | Type | Description |
|------|------|-------------|
| `trackerId` | `Int!` | Tracker ID |
-69
View File
@@ -1,69 +0,0 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn authUrl supportsPrivateTracking scores
statuses { value name }
}
}
}
`;
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private
}
}
}
}
`;
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`;
export const GET_ALL_TRACKER_RECORDS = `
query GetAllTrackerRecords {
trackers {
nodes {
id name icon isLoggedIn scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`;
export const GET_TRACKER_RECORDS = `
query GetTrackerRecords($trackerId: Int!) {
trackers(condition: { id: $trackerId }) {
nodes {
id name
statuses { value name }
trackRecords {
nodes {
id title status displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl }
}
}
}
}
}
`;
+203
View File
@@ -0,0 +1,203 @@
@import '$lib/components/settings/Settings.css';
@import '$lib/styles/themes.css';
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
--titlebar-height: 36px;
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#svelte {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.skeleton {
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
+5
View File
@@ -0,0 +1,5 @@
declare global {
namespace App {}
const __APP_VERSION__: string
}
export {}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
%sveltekit.head%
</head>
<body data-theme="dark">
<div id="svelte">%sveltekit.body%</div>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
export * from "./selectPortal";
-40
View File
@@ -1,40 +0,0 @@
import type { Attachment } from "svelte/attachments";
/**
* {@attach selectPortal(triggerEl)}
*
* Moves the decorated element to <body> and positions it below `triggerEl`.
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
*
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
* the outside-click guard in Settings.svelte can exclude it from dismissal.
*/
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
// Position & move to body
function position() {
const r = triggerEl.getBoundingClientRect();
menuEl.style.position = "fixed";
menuEl.style.top = `${r.bottom + 4}px`;
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
// clamp to viewport left edge
const left = parseFloat(menuEl.style.left);
if (left < 8) menuEl.style.left = "8px";
}
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
position();
// Reposition on scroll / resize while open
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, true);
window.removeEventListener("resize", position);
triggerEl.__selectMenuEl = null;
menuEl.remove();
};
};
}
-50
View File
@@ -1,50 +0,0 @@
import type { Manga, Source } from "@types";
import type { Settings } from "@types";
import { shouldHideSource } from "@core/util";
// ── Source deduplication ──────────────────────────────────────────────────────
/**
* Deduplicates sources by name, preferring `preferredLang` when multiple
* sources share a name. The local source (id "0") is always excluded.
*
* When `applyHide` is true, sources that fail the NSFW/block check are
* also removed — used in fan-out and cache-build paths where only
* user-visible sources should be queried.
*/
export function dedupeSourcesByLang(
sources: Source[],
preferredLang: string,
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
applyHide = false,
): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, settings)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
}
// ── Manga predicate filters ───────────────────────────────────────────────────
/**
* Generic predicate pipeline — composes multiple boolean predicates into one.
* All predicates must return true for an item to pass.
*
* Usage:
* const keep = buildFilter<Manga>(
* m => !shouldHideNsfw(m, settings),
* m => m.inLibrary,
* );
* const filtered = items.filter(keep);
*/
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
}
-5
View File
@@ -1,5 +0,0 @@
export * from './sort';
export * from './filter';
export * from './paginate';
export * from './search';
export * from './queue';
-29
View File
@@ -1,29 +0,0 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
}
export function createAsyncQueue<T>(
worker: (item: T) => Promise<void>,
concurrency = 1,
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() {
while (active < concurrency && queue.length > 0) {
const item = queue.shift()!;
active++;
worker(item).finally(() => { active--; next(); });
}
}
return {
enqueue(item) { queue.push(item); next(); },
drain() { next(); },
clear() { queue.length = 0; },
size() { return queue.length; },
};
}
-33
View File
@@ -1,33 +0,0 @@
export interface SearchResult<T> {
item: T;
score: number;
}
export function searchItems<T>(
items: T[],
query: string,
getField: (item: T) => string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
}
export function searchWithScore<T>(
items: T[],
query: string,
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items
.map(item => {
const field = getField(item).toLowerCase();
if (!field.includes(q)) return null;
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
return { item, score };
})
.filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score);
}
-61
View File
@@ -1,61 +0,0 @@
/**
* Runs an async task over every item in `items`, with at most `concurrency`
* tasks in-flight at once. Respects the provided AbortSignal — each worker
* exits early if the signal fires. Errors thrown by individual tasks are
* swallowed so one failure does not cancel the whole batch.
*/
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, worker),
);
}
/**
* Deduplicates in-flight async calls by key.
*
* Two call signatures are supported:
*
* 1. Direct call — supply a key and a zero-arg factory each time:
* dedupeRequest("my-key", () => fetchSomething())
* If a request with that key is already pending, the existing Promise is
* returned and the factory is not called again.
*
* 2. Curried wrapper — supply a key-based fetcher once, get back a
* single-arg function you can call repeatedly:
* const get = dedupeRequest((key) => fetchSomething(key))
* get("my-key")
*/
const _inflight = new Map<string, Promise<unknown>>();
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
// Curried wrapper form
if (typeof keyOrFn === 'function') {
const fn = keyOrFn;
return (key: string) => dedupeRequest(key, () => fn(key));
}
// Direct call form
const key = keyOrFn;
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
const p = factory!().finally(() => _inflight.delete(key));
_inflight.set(key, p);
return p;
}
-25
View File
@@ -1,25 +0,0 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>;
reset(): void;
hasMore(): boolean;
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
}
export function createPaginatedQuery<T>(
config: PaginatedQueryConfig<T>,
): PaginatedQuery<T> {
let _hasMore = true;
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page);
_hasMore = hasNextPage;
return items;
},
reset() { _hasMore = true; },
hasMore() { return _hasMore; },
};
}
-3
View File
@@ -1,3 +0,0 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
-85
View File
@@ -1,85 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
export const authSession = {
clearTokens() {},
hasSession(): boolean { return true; },
};
function getServerBase(): string {
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
}
function timeoutSignal(ms: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function basicHeader(user: string, pass: string): Record<string, string> {
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
}
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
});
}
return fetch(url, { ...init, signal, credentials: "omit" });
}
export async function loginBasic(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
body: JSON.stringify({ query: "{ __typename }" }),
signal: timeoutSignal(5000),
});
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
}
export async function logout(): Promise<void> {
updateSettings({ serverAuthPass: "" });
}
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (mode === "BASIC_AUTH") {
const user = s.serverAuthUser?.trim() ?? "";
const pass = s.serverAuthPass?.trim() ?? "";
if (user && pass) Object.assign(headers, basicHeader(user, pass));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers,
body: JSON.stringify({ query: "{ __typename }" }),
signal: timeoutSignal(5000),
});
if (res.ok) return "ok";
if (res.status === 401) {
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
if (/basic/i.test(wwwAuth)) {
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
return "auth_required";
}
if (/bearer/i.test(wwwAuth)) {
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
} else if (mode === "NONE") {
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
}
return "unsupported_mode";
}
return "unreachable";
} catch { return "unreachable"; }
}
-38
View File
@@ -1,38 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
function collectAppData(): Record<string, string> {
const data: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
}
return data;
}
function applyAppData(data: Record<string, string>): void {
localStorage.clear();
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
}
}
export async function exportAppData(): Promise<void> {
const json = JSON.stringify(collectAppData(), null, 2);
await invoke("export_app_data", { json });
}
export async function importAppData(): Promise<void> {
const json = await invoke<string>("import_app_data");
const data: Record<string, string> = JSON.parse(json);
applyAppData(data);
location.reload();
}
export async function autoBackupAppData(): Promise<void> {
try {
const json = JSON.stringify(collectAppData());
await invoke("auto_backup_app_data", { json });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
View File
-3
View File
@@ -1,3 +0,0 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
export type { Keybinds } from "./defaultBinds";
-67
View File
@@ -1,67 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
function applySystemTheme(dark: boolean) {
const themeId = dark
? (store.settings.systemThemeDark ?? "dark")
: (store.settings.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!store.settings.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}
-23
View File
@@ -1,23 +0,0 @@
import { store } from "@store/state.svelte";
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
if (timer) clearTimeout(timer);
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
timer = setTimeout(onIdle, ms);
onActive();
}
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
reset();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
};
}
-2
View File
@@ -1,2 +0,0 @@
export * from './idle';
export * from './zoom';
-40
View File
@@ -1,40 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { addToast } from "@store/state.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
addToast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
-48
View File
@@ -1,48 +0,0 @@
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.anim-fade-in { animation: fadeIn 0.14s ease both; }
.anim-fade-up { animation: fadeUp 0.18s ease both; }
.anim-fade-down { animation: fadeDown 0.18s ease both; }
.anim-scale-in { animation: scaleIn 0.14s ease both; }
.anim-pulse { animation: pulse 1.6s ease infinite; }
.anim-spin { animation: spin 0.7s linear infinite; }
.skeleton {
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s ease infinite;
border-radius: var(--radius-sm);
}
-4
View File
@@ -1,4 +0,0 @@
@import "./reset.css";
@import "./animations.css";
@import "./scrollbars.css";
@import "./typography.css";
-41
View File
@@ -1,41 +0,0 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
}
#app {
height: 100%;
}
button {
cursor: pointer;
font: inherit;
color: inherit;
background: none;
border: none;
padding: 0;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
img, svg { display: block; max-width: 100%; }
p { margin: 0; }
-9
View File
@@ -1,9 +0,0 @@
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
*::-webkit-scrollbar-thumb:hover { background: transparent; }
-9
View File
@@ -1,9 +0,0 @@
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="dark"] {
--bg-void: #000000;
--bg-base: #080808;
--bg-surface: #0d0d0d;
--bg-raised: #111111;
--bg-overlay: #171717;
--bg-subtle: #1e1e1e;
--border-dim: #252525;
--border-base: #303030;
--border-strong: #3e3e3e;
--border-focus: #5a7a5a;
--text-primary: #ffffff;
--text-secondary: #e8e6e0;
--text-muted: #b0aea8;
--text-faint: #6e6c68;
--text-disabled: #303030;
--accent: #7aaa7a;
--accent-dim: #2e4a2e;
--accent-muted: #1e2e1e;
--accent-fg: #bcd8bc;
--accent-bright: #9fcf9f;
}
-5
View File
@@ -1,5 +0,0 @@
@import "./original.css";
@import "./dark.css";
@import "./light.css";
@import "./midnight.css";
@import "./warm.css";
-29
View File
@@ -1,29 +0,0 @@
[data-theme="light"] {
--bg-void: #d8d4ce;
--bg-base: #e2deda;
--bg-surface: #ece8e2;
--bg-raised: #f5f2ec;
--bg-overlay: #ffffff;
--bg-subtle: #e4e0d8;
--border-dim: #c4c0b8;
--border-base: #b0aca4;
--border-strong: #989490;
--border-focus: #3a5a3a;
--text-primary: #080806;
--text-secondary: #181612;
--text-muted: #38342e;
--text-faint: #706c64;
--text-disabled: #b0aca4;
--accent: #2a5a2a;
--accent-dim: #b0ccb0;
--accent-muted: #c8dcc8;
--accent-fg: #183818;
--accent-bright: #1e4e1e;
--color-error: #8a1a1a;
--color-error-bg: #f8e0e0;
--color-read: #e0dcd4;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="midnight"] {
--bg-void: #050810;
--bg-base: #080c18;
--bg-surface: #0c1020;
--bg-raised: #101428;
--bg-overlay: #151a30;
--bg-subtle: #1a2038;
--border-dim: #1a2035;
--border-base: #222840;
--border-strong: #2c3450;
--border-focus: #4a5c8a;
--text-primary: #eeeef8;
--text-secondary: #c0c4d8;
--text-muted: #808498;
--text-faint: #404860;
--text-disabled: #202840;
--accent: #6a7ab8;
--accent-dim: #252d50;
--accent-muted: #181e38;
--accent-fg: #a8b4e8;
--accent-bright: #8896d0;
}
-31
View File
@@ -1,31 +0,0 @@
[data-theme="original"] {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
}
-25
View File
@@ -1,25 +0,0 @@
[data-theme="warm"] {
--bg-void: #0c0a06;
--bg-base: #100e08;
--bg-surface: #16130c;
--bg-raised: #1c1810;
--bg-overlay: #221e14;
--bg-subtle: #28241a;
--border-dim: #201c10;
--border-base: #2c2818;
--border-strong: #3a3420;
--border-focus: #6a5a30;
--text-primary: #f5f0e0;
--text-secondary: #d8d0b0;
--text-muted: #988c60;
--text-faint: #584e30;
--text-disabled: #302a18;
--accent: #c0902a;
--accent-dim: #3a2c10;
--accent-muted: #261e0c;
--accent-fg: #e0b860;
--accent-bright: #d0a040;
}
-37
View File
@@ -1,37 +0,0 @@
:root {
--bg-void: #080808;
--bg-base: #0c0c0c;
--bg-surface: #101010;
--bg-raised: #151515;
--bg-overlay: #1a1a1a;
--bg-subtle: #202020;
--border-dim: #1c1c1c;
--border-base: #242424;
--border-strong: #2e2e2e;
--border-focus: #4a5c4a;
--text-primary: #f0efec;
--text-secondary: #c8c6c0;
--text-muted: #8a8880;
--text-faint: #4e4d4a;
--text-disabled: #2a2a28;
--accent: #6b8f6b;
--accent-dim: #2a3d2a;
--accent-muted: #1a251a;
--accent-fg: #a8c4a8;
--accent-bright: #8fb88f;
--color-error: #c47a7a;
--color-error-bg: #1f1212;
--color-success: #7aab7a;
--color-info: #7a9ec4;
--color-info-bg: #121a1f;
--color-read: #2e2e2c;
--dot-active: var(--accent);
--dot-inactive: var(--text-faint);
--bg-image: none;
}
-8
View File
@@ -1,8 +0,0 @@
@import "./colors.css";
@import "./typography.css";
@import "./spacing.css";
@import "./radius.css";
@import "./motion.css";
@import "./shadows.css";
@import "./zindex.css";
@import "../themes/index.css";
-5
View File
@@ -1,5 +0,0 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
-8
View File
@@ -1,8 +0,0 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
-2
View File
@@ -1,2 +0,0 @@
:root {
}
-12
View File
@@ -1,12 +0,0 @@
:root {
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sidebar-width: 52px;
}
-28
View File
@@ -1,28 +0,0 @@
:root {
--font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 15px;
--text-xl: 17px;
--text-2xl: 20px;
--text-3xl: 24px;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
--leading-none: 1;
--leading-tight: 1.3;
--leading-snug: 1.45;
--leading-base: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-wider: 0.1em;
}
-5
View File
@@ -1,5 +0,0 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
View File
View File
View File
-2
View File
@@ -1,2 +0,0 @@
export { default as Search } from "./components/Search.svelte";
export * from "./lib/searchFilter";
@@ -1,52 +0,0 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import DownloadItem from "./DownloadItem.svelte";
import type { DownloadQueueItem } from "@types/index";
interface Props {
queue: DownloadQueueItem[];
loading: boolean;
isRunning: boolean;
dequeueing: Set<number>;
selected: Set<number>;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
}: Props = $props();
</script>
{#if loading}
<div class="empty">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if queue.length === 0}
<div class="empty">Queue is empty.</div>
{:else}
<div class="list">
{#each queue as item, i (item.chapter.id)}
<DownloadItem
{item}
isActive={i === 0 && isRunning}
isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)}
{onRemove}
{onRetry}
{onReorder}
{onReorderEdge}
{onSelect}
/>
{/each}
</div>
{/if}
<style>
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
</style>
-2
View File
@@ -1,2 +0,0 @@
export { downloadStore } from "./store/downloadState.svelte";
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";

Some files were not shown because too many files have changed in this diff Show More