Compare commits

...

108 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
367 changed files with 24619 additions and 18535 deletions
+20 -8
View File
@@ -1,26 +1,33 @@
# --- Build Artifacts ---
node_modules/
suwayomi-raw/
suwayomi-windows.zip
dist/
dist-tauri/
target/
bin/
out/
# --- Nix ---
.direnv/
result
result-*
# --- Logs ---
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example
!.env.test
.env.local
.env.*.local
# --- IDEs & OS ---
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
.vscode/
.idea/
.DS_Store
@@ -30,14 +37,19 @@ yarn-error.log*
*.sln
*.swp
# --- Tauri specific ---
src-tauri/target/
src-tauri/binaries/
src-tauri/gen/
# --- Flatpak build artifacts ---
.DS_Store
Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
build-dir/
repo/
dist/
packaging/frontend-dist.tar.gz
*.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
+36 -17
View File
@@ -1,5 +1,5 @@
pkgname=moku
pkgver=0.9.2
pkgver=0.9.4
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
@@ -13,27 +13,46 @@ depends=(
)
makedepends=(
'rust'
'cargo'
'nodejs'
'pnpm'
)
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"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=(
'4d0fbed929d5660ddcb591ff33f808910e13df1e8e7bfc8df83f367fd7bcd881'
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
'SKIP'
'SKIP'
)
prepare() {
cd "Moku-$pkgver"
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() {
cd "Moku-$pkgver"
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 \
--release \
--manifest-path src-tauri/Cargo.toml
@@ -68,14 +87,14 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR"
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
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = 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"
@@ -87,12 +106,12 @@ export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec java \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
LAUNCHER
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
@@ -105,6 +124,6 @@ LAUNCHER
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 LICENSE \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+7 -7
View File
@@ -33,7 +33,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
</div>
<div align="center">
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
<a href="docs/screenshots">View all screenshots →</a>
</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
- **Extension support** — install and manage Suwayomi extensions directly from the app
- **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)
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
- **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
- **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
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **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 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>
@@ -148,9 +148,9 @@ pnpm tauri:dev
| | |
|---|---|
| [Tauri v2](https://tauri.app) | Native app shell |
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite 8](https://vitejs.dev) | Frontend bundler |
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
---
+2 -40
View File
@@ -1,42 +1,4 @@
Major Revisions:
- 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
Revival of the TODO List!!!!!
Minor Revisions:
- Investigate feasibility of Multi-Page Screenshot (Reader)
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
Priority Bugs:
- Fix Library-Refresh System (TESTING)
- Suwayomi RESET
- Allow User to Wipe Suwayomi (Scratch)
- If Possible, Component based Wipe (Library, Etc)
Pending/On-Hold:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Working on 3D Display Cards
- Add Flathub Support (Pending Video)
- Change Auto-Link Threshold
- Fix Auto-Link De-dupe for Images
- Optimize Auto-Link Latency (IP)
In-Progress:
- Fix Tracking Login
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
- Note User's have to always install extensions manually
- Create "Missing Source" for Manga
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
- Add Disable Auto-Completed Feature to Library
- Cap ReaderSettings Zoom (100)
- Fix SeriesDetail Chapter Amount (Link to Scanlator Filtering)
Notes from last time:
- Reminder to Completely Test Settings
Generated
+12 -28
View File
@@ -1,30 +1,15 @@
{
"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": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -35,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"lastModified": 1780243769,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"type": "github"
},
"original": {
@@ -51,11 +36,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github"
},
"original": {
@@ -66,7 +51,6 @@
},
"root": {
"inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
@@ -79,11 +63,11 @@
]
},
"locked": {
"lastModified": 1773975983,
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
"lastModified": 1780543271,
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
"type": "github"
},
"original": {
+31 -43
View File
@@ -4,7 +4,6 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
@@ -12,7 +11,7 @@
};
outputs =
inputs@{ flake-parts, crane, rust-overlay, ... }:
inputs@{ flake-parts, rust-overlay, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
@@ -22,7 +21,8 @@
perSystem =
{ system, lib, ... }:
let
version = "0.9.2";
versions = import ./nix/versions.nix;
version = versions.moku;
pkgs = import inputs.nixpkgs {
inherit system;
@@ -36,8 +36,6 @@
];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
runtimeLibs = with pkgs; [
webkitgtk_4_1
gtk3
@@ -53,9 +51,7 @@
gsettings-desktop-schemas
];
# ── source filters ──────────────────────────────────────────────
frontendSrc = lib.cleanSourceWith {
src = lib.cleanSourceWith {
src = ./.;
filter =
path: type:
@@ -63,55 +59,46 @@
base = builtins.baseNameOf path;
in
(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 == "package.json"
|| base == "pnpm-lock.yaml"
|| base == "pnpm-workspace.yaml"
|| 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";
};
cargoSrc = lib.cleanSourceWith {
src = ./src-tauri;
filter =
path: type:
(craneLib.filterCargoSources path type)
|| (lib.hasInfix "/icons/" path)
|| (lib.hasInfix "/capabilities/" path)
|| (builtins.baseNameOf path == "tauri.conf.json");
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
moku = pkgs.callPackage ./nix/moku.nix {
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
appIcon = ./src/lib/assets/moku-icon.svg;
};
# ── packages ────────────────────────────────────────────────────
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
frontend = pkgs.callPackage ./nix/frontend.nix {
inherit version;
src = frontendSrc;
};
moku = import ./nix/moku.nix {
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
appIcon = ./src/assets/moku-icon.svg;
};
# ── dev/release scripts ─────────────────────────────────────────
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
in
{
packages = {
inherit moku frontend suwayomiServer;
inherit moku suwayomiServer;
default = moku;
};
apps = {
default = { type = "app"; program = "${moku}/bin/moku"; };
moku = { type = "app"; program = "${moku}/bin/moku"; };
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
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"; };
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
};
devShells.default = pkgs.mkShell {
@@ -134,14 +121,15 @@
export NO_STRIP=true
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 LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
echo "Moku dev shell pnpm install && pnpm tauri:dev"
echo ""
echo " nix run .#bump -- <ver>"
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
echo " git commit && git tag && git push"
echo " nix run .#post-tag-bump -- <ver>"
echo " nix run .#flatpak -- <ver>"
echo " nix run .#tunnel -- [port]"
echo " nix run .#update -- <ver> fetch hashes + patch all configs"
echo " nix run .#flatpak build flatpak bundle"
echo " nix run .#tunnel -- [port] expose local server via cloudflare"
'';
};
+79 -14
View File
@@ -32,6 +32,77 @@ build-options:
CARGO_HOME: /run/build/moku/cargo
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
buildsystem: simple
build-commands:
@@ -52,9 +123,6 @@ modules:
- type: inline
dest-filename: catch_abort.c
contents: |
// Linux only:
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
@@ -95,12 +163,12 @@ modules:
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.webUIChannel = "PREVIEW"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
@@ -117,20 +185,17 @@ modules:
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR"
# Seed conf on first run
if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# Append keys if absent
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\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -166,10 +231,10 @@ modules:
CARGO_HOME: /run/build/moku/cargo
XDG_DATA_HOME: /run/build/moku/xdg-data
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:
- 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 -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
@@ -179,11 +244,11 @@ modules:
sources:
- type: git
url: https://github.com/moku-project/Moku.git
tag: v0.9.2
commit: e33464b05baddc7c4ad3815f3f126f791e8c58cc
tag: v0.9.4
commit: 239960683b6c7f1347e1798b0e179a8a46628728
- type: file
path: packaging/frontend-dist.tar.gz
sha256: 22128c591ddacac218b7223106ed3c3f052799db2a647247789492b925370086
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
-18
View File
@@ -1,18 +0,0 @@
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
stdenv.mkDerivation {
pname = "moku-frontend";
inherit version src;
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
pnpmDeps = fetchPnpmDeps {
pname = "moku-frontend";
inherit version src;
fetcherVersion = 1;
hash = "sha256-t6Gj84hCE3CuDAJfbdXi0FuqgPCqlkMmAzETcKL4e3U=";
};
buildPhase = "pnpm build";
installPhase = "cp -r dist $out";
}
+51 -26
View File
@@ -1,38 +1,61 @@
{
lib,
craneLib,
pkgs,
rustToolchain,
runtimeLibs,
frontend,
suwayomiServer,
version,
cargoSrc,
versions,
src,
appIcon,
}:
let
commonArgs = {
src = cargoSrc;
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;
strictDeps = true;
buildInputs = runtimeLibs;
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
preBuild = ''
cp -r ${frontend} ../dist
'';
inherit version src;
fetcherVersion = 3;
hash = versions.frontend.pnpmHash;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
cargoDeps = pkgs.rustPlatform.importCargoLock {
lockFile = ../src-tauri/Cargo.lock;
outputHashes = {
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
};
};
meta.mainProgram = "moku";
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
postInstall = ''
mkdir -p "$out/share/applications"
cat > "$out/share/applications/moku.desktop" << EOF
cat > "$out/share/applications/moku.desktop" << EOF2
[Desktop Entry]
Version=1.0
Type=Application
@@ -44,17 +67,17 @@ Terminal=false
Categories=Graphics;Viewer;
Keywords=manga;comic;reader;suwayomi;
StartupWMClass=moku
EOF
EOF2
for size in 32x32 128x128 256x256 512x512; do
src="icons/$size.png"
[ -f "$src" ] && install -Dm644 "$src" \
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
src="icons/''${size}@2x.png"
[ -f "$src" ] && install -Dm644 "$src" \
f="src-tauri/icons/''${size}@2x.png"
[ -f "$f" ] && install -Dm644 "$f" \
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
done
@@ -71,4 +94,6 @@ EOF
--set GDK_BACKEND wayland \
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
'';
})
meta.mainProgram = "moku";
}
+68 -61
View File
@@ -1,4 +1,4 @@
{ pkgs, rustToolchain, version }:
{ pkgs, rustToolchain, version, versions }:
{
bump = pkgs.writeShellApplication {
@@ -17,87 +17,98 @@
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/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"
echo "Done"
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
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"
pnpm build:static
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"
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
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 "
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 commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
echo "Bumped to v$VERSION commit, tag, push, then: nix run .#update -- $VERSION"
'';
};
postTagBump = pkgs.writeShellApplication {
name = "moku-post-tag-bump";
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
update = pkgs.writeShellApplication {
name = "moku-update";
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
VERSION="$1"
REPO="$(git rev-parse --show-toplevel)"
VERSIONS="$REPO/nix/versions.nix"
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"
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"
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/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$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"
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"
'';
};
@@ -105,19 +116,15 @@
name = "moku-flatpak";
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
text = ''
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
REPO="$(git rev-parse --show-toplevel)"
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
rm -rf "$REPO/build-dir" "$REPO/repo"
flatpak-builder \
--repo="$REPO/repo" \
--force-clean \
"$REPO/build-dir" \
"$MANIFEST"
"$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"
'';
};
+8 -6
View File
@@ -4,17 +4,19 @@
fetchurl,
makeWrapper,
jdk21_headless,
versions,
}:
let
jdk = jdk21_headless;
ver = versions.suwayomi;
in
stdenvNoCC.mkDerivation (finalAttrs: {
stdenvNoCC.mkDerivation {
pname = "suwayomi-server";
version = "2.1.2087";
version = ver.version;
src = fetchurl {
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
hash = ver.hash;
};
nativeBuildInputs = [ makeWrapper ];
@@ -37,10 +39,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
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${finalAttrs.version}";
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 = "";
}
+39 -23
View File
@@ -1,32 +1,48 @@
{
"name": "moku",
"version": "0.5.0",
"private": true,
"version": "0.9.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "vite dev",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "~2.4.2",
"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"
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"build:static": "MOKU_TARGET=static vite build",
"build:node": "MOKU_TARGET=node vite build",
"build:android": "MOKU_TARGET=static vite build",
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "tauri build"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tauri-apps/cli": "^2.0.0",
"svelte": "^5.0.0",
"svelte-check": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.62.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2.11.2",
"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"
}
}
+188 -266
View File
@@ -265,6 +265,19 @@
"dest": "cargo/vendor/brotli-decompressor-5.0.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/bs58/bs58-0.5.1.crate",
"sha256": "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4",
"dest": "cargo/vendor/bs58-0.5.1"
},
{
"type": "inline",
"contents": "{\"package\": \"bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4\", \"files\": {}}",
"dest": "cargo/vendor/bs58-0.5.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -398,14 +411,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/cc/cc-1.2.61.crate",
"sha256": "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d",
"dest": "cargo/vendor/cc-1.2.61"
"url": "https://static.crates.io/crates/cc/cc-1.2.62.crate",
"sha256": "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98",
"dest": "cargo/vendor/cc-1.2.62"
},
{
"type": "inline",
"contents": "{\"package\": \"d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.61",
"contents": "{\"package\": \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\", \"files\": {}}",
"dest": "cargo/vendor/cc-1.2.62",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -1750,14 +1763,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/h2/h2-0.4.13.crate",
"sha256": "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54",
"dest": "cargo/vendor/h2-0.4.13"
"url": "https://static.crates.io/crates/h2/h2-0.4.14.crate",
"sha256": "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733",
"dest": "cargo/vendor/h2-0.4.14"
},
{
"type": "inline",
"contents": "{\"package\": \"2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54\", \"files\": {}}",
"dest": "cargo/vendor/h2-0.4.13",
"contents": "{\"package\": \"171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733\", \"files\": {}}",
"dest": "cargo/vendor/h2-0.4.14",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -1789,14 +1802,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.0.crate",
"sha256": "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51",
"dest": "cargo/vendor/hashbrown-0.17.0"
"url": "https://static.crates.io/crates/hashbrown/hashbrown-0.17.1.crate",
"sha256": "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a",
"dest": "cargo/vendor/hashbrown-0.17.1"
},
{
"type": "inline",
"contents": "{\"package\": \"4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51\", \"files\": {}}",
"dest": "cargo/vendor/hashbrown-0.17.0",
"contents": "{\"package\": \"ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a\", \"files\": {}}",
"dest": "cargo/vendor/hashbrown-0.17.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -2189,19 +2202,6 @@
"dest": "cargo/vendor/ipnet-2.12.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/iri-string/iri-string-0.7.12.crate",
"sha256": "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20",
"dest": "cargo/vendor/iri-string-0.7.12"
},
{
"type": "inline",
"contents": "{\"package\": \"25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20\", \"files\": {}}",
"dest": "cargo/vendor/iri-string-0.7.12",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -2322,14 +2322,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.97.crate",
"sha256": "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf",
"dest": "cargo/vendor/js-sys-0.3.97"
"url": "https://static.crates.io/crates/js-sys/js-sys-0.3.98.crate",
"sha256": "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08",
"dest": "cargo/vendor/js-sys-0.3.98"
},
{
"type": "inline",
"contents": "{\"package\": \"a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.97",
"contents": "{\"package\": \"67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08\", \"files\": {}}",
"dest": "cargo/vendor/js-sys-0.3.98",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3011,27 +3011,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/open/open-5.3.4.crate",
"sha256": "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd",
"dest": "cargo/vendor/open-5.3.4"
"url": "https://static.crates.io/crates/open/open-5.3.5.crate",
"sha256": "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c",
"dest": "cargo/vendor/open-5.3.5"
},
{
"type": "inline",
"contents": "{\"package\": \"9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd\", \"files\": {}}",
"dest": "cargo/vendor/open-5.3.4",
"contents": "{\"package\": \"2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c\", \"files\": {}}",
"dest": "cargo/vendor/open-5.3.5",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl/openssl-0.10.78.crate",
"sha256": "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222",
"dest": "cargo/vendor/openssl-0.10.78"
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
"dest": "cargo/vendor/openssl-0.10.80"
},
{
"type": "inline",
"contents": "{\"package\": \"f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.78",
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.80",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3063,14 +3063,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.114.crate",
"sha256": "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6",
"dest": "cargo/vendor/openssl-sys-0.9.114"
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
"dest": "cargo/vendor/openssl-sys-0.9.116"
},
{
"type": "inline",
"contents": "{\"package\": \"13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.114",
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.116",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3190,19 +3190,6 @@
"dest": "cargo/vendor/percent-encoding-2.3.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf/phf-0.11.3.crate",
"sha256": "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078",
"dest": "cargo/vendor/phf-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\", \"files\": {}}",
"dest": "cargo/vendor/phf-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3229,19 +3216,6 @@
"dest": "cargo/vendor/phf_codegen-0.13.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_generator/phf_generator-0.11.3.crate",
"sha256": "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d",
"dest": "cargo/vendor/phf_generator-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\", \"files\": {}}",
"dest": "cargo/vendor/phf_generator-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3255,19 +3229,6 @@
"dest": "cargo/vendor/phf_generator-0.13.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_macros/phf_macros-0.11.3.crate",
"sha256": "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216",
"dest": "cargo/vendor/phf_macros-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\", \"files\": {}}",
"dest": "cargo/vendor/phf_macros-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3281,19 +3242,6 @@
"dest": "cargo/vendor/phf_macros-0.13.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/phf_shared/phf_shared-0.11.3.crate",
"sha256": "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5",
"dest": "cargo/vendor/phf_shared-0.11.3"
},
{
"type": "inline",
"contents": "{\"package\": \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\", \"files\": {}}",
"dest": "cargo/vendor/phf_shared-0.11.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3544,14 +3492,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.2.crate",
"sha256": "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d",
"dest": "cargo/vendor/quick-xml-0.39.2"
"url": "https://static.crates.io/crates/quick-xml/quick-xml-0.39.4.crate",
"sha256": "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e",
"dest": "cargo/vendor/quick-xml-0.39.4"
},
{
"type": "inline",
"contents": "{\"package\": \"958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d\", \"files\": {}}",
"dest": "cargo/vendor/quick-xml-0.39.2",
"contents": "{\"package\": \"cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e\", \"files\": {}}",
"dest": "cargo/vendor/quick-xml-0.39.4",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3632,19 +3580,6 @@
"dest": "cargo/vendor/r-efi-6.0.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand/rand-0.8.6.crate",
"sha256": "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a",
"dest": "cargo/vendor/rand-0.8.6"
},
{
"type": "inline",
"contents": "{\"package\": \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\", \"files\": {}}",
"dest": "cargo/vendor/rand-0.8.6",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3671,19 +3606,6 @@
"dest": "cargo/vendor/rand_chacha-0.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rand_core/rand_core-0.6.4.crate",
"sha256": "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c",
"dest": "cargo/vendor/rand_core-0.6.4"
},
{
"type": "inline",
"contents": "{\"package\": \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\", \"files\": {}}",
"dest": "cargo/vendor/rand_core-0.6.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -4272,27 +4194,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with/serde_with-3.18.0.crate",
"sha256": "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f",
"dest": "cargo/vendor/serde_with-3.18.0"
"url": "https://static.crates.io/crates/serde_with/serde_with-3.20.0.crate",
"sha256": "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2",
"dest": "cargo/vendor/serde_with-3.20.0"
},
{
"type": "inline",
"contents": "{\"package\": \"dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f\", \"files\": {}}",
"dest": "cargo/vendor/serde_with-3.18.0",
"contents": "{\"package\": \"e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2\", \"files\": {}}",
"dest": "cargo/vendor/serde_with-3.20.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.18.0.crate",
"sha256": "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65",
"dest": "cargo/vendor/serde_with_macros-3.18.0"
"url": "https://static.crates.io/crates/serde_with_macros/serde_with_macros-3.20.0.crate",
"sha256": "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac",
"dest": "cargo/vendor/serde_with_macros-3.20.0"
},
{
"type": "inline",
"contents": "{\"package\": \"d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65\", \"files\": {}}",
"dest": "cargo/vendor/serde_with_macros-3.18.0",
"contents": "{\"package\": \"b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac\", \"files\": {}}",
"dest": "cargo/vendor/serde_with_macros-3.20.0",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4428,14 +4350,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/siphasher/siphasher-1.0.2.crate",
"sha256": "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e",
"dest": "cargo/vendor/siphasher-1.0.2"
"url": "https://static.crates.io/crates/siphasher/siphasher-1.0.3.crate",
"sha256": "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649",
"dest": "cargo/vendor/siphasher-1.0.3"
},
{
"type": "inline",
"contents": "{\"package\": \"b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e\", \"files\": {}}",
"dest": "cargo/vendor/siphasher-1.0.2",
"contents": "{\"package\": \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\", \"files\": {}}",
"dest": "cargo/vendor/siphasher-1.0.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4727,14 +4649,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tao/tao-0.35.0.crate",
"sha256": "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159",
"dest": "cargo/vendor/tao-0.35.0"
"url": "https://static.crates.io/crates/tao/tao-0.35.2.crate",
"sha256": "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4",
"dest": "cargo/vendor/tao-0.35.2"
},
{
"type": "inline",
"contents": "{\"package\": \"1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159\", \"files\": {}}",
"dest": "cargo/vendor/tao-0.35.0",
"contents": "{\"package\": \"a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4\", \"files\": {}}",
"dest": "cargo/vendor/tao-0.35.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4766,79 +4688,79 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri/tauri-2.11.0.crate",
"sha256": "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66",
"dest": "cargo/vendor/tauri-2.11.0"
"url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
"sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
"dest": "cargo/vendor/tauri-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66\", \"files\": {}}",
"dest": "cargo/vendor/tauri-2.11.0",
"contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
"dest": "cargo/vendor/tauri-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.0.crate",
"sha256": "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988",
"dest": "cargo/vendor/tauri-build-2.6.0"
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
"sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
"dest": "cargo/vendor/tauri-build-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988\", \"files\": {}}",
"dest": "cargo/vendor/tauri-build-2.6.0",
"contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
"dest": "cargo/vendor/tauri-build-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.0.crate",
"sha256": "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e",
"dest": "cargo/vendor/tauri-codegen-2.6.0"
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
"sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
"dest": "cargo/vendor/tauri-codegen-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e\", \"files\": {}}",
"dest": "cargo/vendor/tauri-codegen-2.6.0",
"contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-codegen-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.0.crate",
"sha256": "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc",
"dest": "cargo/vendor/tauri-macros-2.6.0"
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
"sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
"dest": "cargo/vendor/tauri-macros-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc\", \"files\": {}}",
"dest": "cargo/vendor/tauri-macros-2.6.0",
"contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
"dest": "cargo/vendor/tauri-macros-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.0.crate",
"sha256": "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57",
"dest": "cargo/vendor/tauri-plugin-2.6.0"
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
"sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
"dest": "cargo/vendor/tauri-plugin-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-2.6.0",
"contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.0.crate",
"sha256": "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0"
"url": "https://static.crates.io/crates/tauri-plugin-dialog/tauri-plugin-dialog-2.7.1.crate",
"sha256": "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.1"
},
{
"type": "inline",
"contents": "{\"package\": \"a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.0",
"contents": "{\"package\": \"65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-dialog-2.7.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4862,27 +4784,27 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.0.crate",
"sha256": "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0"
"url": "https://static.crates.io/crates/tauri-plugin-fs/tauri-plugin-fs-2.5.1.crate",
"sha256": "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.1"
},
{
"type": "inline",
"contents": "{\"package\": \"36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.0",
"contents": "{\"package\": \"b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-fs-2.5.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.8.crate",
"sha256": "cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d",
"dest": "cargo/vendor/tauri-plugin-http-2.5.8"
"url": "https://static.crates.io/crates/tauri-plugin-http/tauri-plugin-http-2.5.9.crate",
"sha256": "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067",
"dest": "cargo/vendor/tauri-plugin-http-2.5.9"
},
{
"type": "inline",
"contents": "{\"package\": \"cfba7d4ec72763f9d1fdf73c217747f01e2c84b08b87a8cacd2f94f35853f84d\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-http-2.5.8",
"contents": "{\"package\": \"b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-http-2.5.9",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4927,53 +4849,53 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.2.crate",
"sha256": "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea",
"dest": "cargo/vendor/tauri-plugin-store-2.4.2"
"url": "https://static.crates.io/crates/tauri-plugin-store/tauri-plugin-store-2.4.3.crate",
"sha256": "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b",
"dest": "cargo/vendor/tauri-plugin-store-2.4.3"
},
{
"type": "inline",
"contents": "{\"package\": \"5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-store-2.4.2",
"contents": "{\"package\": \"6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-store-2.4.3",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.0.crate",
"sha256": "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95",
"dest": "cargo/vendor/tauri-runtime-2.11.0"
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
"sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
"dest": "cargo/vendor/tauri-runtime-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-2.11.0",
"contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.0.crate",
"sha256": "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0"
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
"sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.0",
"contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.0.crate",
"sha256": "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7",
"dest": "cargo/vendor/tauri-utils-2.9.0"
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
"sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
"dest": "cargo/vendor/tauri-utils-2.9.2"
},
{
"type": "inline",
"contents": "{\"package\": \"55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7\", \"files\": {}}",
"dest": "cargo/vendor/tauri-utils-2.9.0",
"contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
"dest": "cargo/vendor/tauri-utils-2.9.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5148,14 +5070,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tokio/tokio-1.52.1.crate",
"sha256": "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6",
"dest": "cargo/vendor/tokio-1.52.1"
"url": "https://static.crates.io/crates/tokio/tokio-1.52.3.crate",
"sha256": "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe",
"dest": "cargo/vendor/tokio-1.52.3"
},
{
"type": "inline",
"contents": "{\"package\": \"b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6\", \"files\": {}}",
"dest": "cargo/vendor/tokio-1.52.1",
"contents": "{\"package\": \"8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe\", \"files\": {}}",
"dest": "cargo/vendor/tokio-1.52.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5369,14 +5291,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.8.crate",
"sha256": "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8",
"dest": "cargo/vendor/tower-http-0.6.8"
"url": "https://static.crates.io/crates/tower-http/tower-http-0.6.10.crate",
"sha256": "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51",
"dest": "cargo/vendor/tower-http-0.6.10"
},
{
"type": "inline",
"contents": "{\"package\": \"d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8\", \"files\": {}}",
"dest": "cargo/vendor/tower-http-0.6.8",
"contents": "{\"package\": \"68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51\", \"files\": {}}",
"dest": "cargo/vendor/tower-http-0.6.10",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5837,66 +5759,66 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.120.crate",
"sha256": "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1",
"dest": "cargo/vendor/wasm-bindgen-0.2.120"
"url": "https://static.crates.io/crates/wasm-bindgen/wasm-bindgen-0.2.121.crate",
"sha256": "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790",
"dest": "cargo/vendor/wasm-bindgen-0.2.121"
},
{
"type": "inline",
"contents": "{\"package\": \"df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.120",
"contents": "{\"package\": \"49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-0.2.121",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.70.crate",
"sha256": "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70"
"url": "https://static.crates.io/crates/wasm-bindgen-futures/wasm-bindgen-futures-0.4.71.crate",
"sha256": "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71"
},
{
"type": "inline",
"contents": "{\"package\": \"af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.70",
"contents": "{\"package\": \"96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-futures-0.4.71",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.120.crate",
"sha256": "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120"
"url": "https://static.crates.io/crates/wasm-bindgen-macro/wasm-bindgen-macro-0.2.121.crate",
"sha256": "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121"
},
{
"type": "inline",
"contents": "{\"package\": \"78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.120",
"contents": "{\"package\": \"8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-0.2.121",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.120.crate",
"sha256": "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120"
"url": "https://static.crates.io/crates/wasm-bindgen-macro-support/wasm-bindgen-macro-support-0.2.121.crate",
"sha256": "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121"
},
{
"type": "inline",
"contents": "{\"package\": \"9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.120",
"contents": "{\"package\": \"d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-macro-support-0.2.121",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.120.crate",
"sha256": "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120"
"url": "https://static.crates.io/crates/wasm-bindgen-shared/wasm-bindgen-shared-0.2.121.crate",
"sha256": "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121"
},
{
"type": "inline",
"contents": "{\"package\": \"49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.120",
"contents": "{\"package\": \"c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441\", \"files\": {}}",
"dest": "cargo/vendor/wasm-bindgen-shared-0.2.121",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -5954,14 +5876,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.97.crate",
"sha256": "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602",
"dest": "cargo/vendor/web-sys-0.3.97"
"url": "https://static.crates.io/crates/web-sys/web-sys-0.3.98.crate",
"sha256": "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa",
"dest": "cargo/vendor/web-sys-0.3.98"
},
{
"type": "inline",
"contents": "{\"package\": \"2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.97",
"contents": "{\"package\": \"4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa\", \"files\": {}}",
"dest": "cargo/vendor/web-sys-0.3.98",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7046,14 +6968,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/winnow/winnow-1.0.2.crate",
"sha256": "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0",
"dest": "cargo/vendor/winnow-1.0.2"
"url": "https://static.crates.io/crates/winnow/winnow-1.0.3.crate",
"sha256": "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1",
"dest": "cargo/vendor/winnow-1.0.3"
},
{
"type": "inline",
"contents": "{\"package\": \"2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.2",
"contents": "{\"package\": \"0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1\", \"files\": {}}",
"dest": "cargo/vendor/winnow-1.0.3",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7176,14 +7098,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/wry/wry-0.55.0.crate",
"sha256": "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429",
"dest": "cargo/vendor/wry-0.55.0"
"url": "https://static.crates.io/crates/wry/wry-0.55.1.crate",
"sha256": "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514",
"dest": "cargo/vendor/wry-0.55.1"
},
{
"type": "inline",
"contents": "{\"package\": \"3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429\", \"files\": {}}",
"dest": "cargo/vendor/wry-0.55.0",
"contents": "{\"package\": \"186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514\", \"files\": {}}",
"dest": "cargo/vendor/wry-0.55.1",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -7267,14 +7189,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.7.crate",
"sha256": "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df",
"dest": "cargo/vendor/zerofrom-0.1.7"
"url": "https://static.crates.io/crates/zerofrom/zerofrom-0.1.8.crate",
"sha256": "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272",
"dest": "cargo/vendor/zerofrom-0.1.8"
},
{
"type": "inline",
"contents": "{\"package\": \"69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df\", \"files\": {}}",
"dest": "cargo/vendor/zerofrom-0.1.7",
"contents": "{\"package\": \"0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272\", \"files\": {}}",
"dest": "cargo/vendor/zerofrom-0.1.8",
"dest-filename": ".cargo-checksum.json"
},
{
+1050 -884
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
+196 -254
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "moku"
version = "0.9.2"
version = "0.9.4"
edition = "2021"
[lib]
@@ -15,7 +15,7 @@ path = "src/main.rs"
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.0", features = [] }
tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-http = "2"
@@ -61,12 +61,12 @@ if [ ! -f "$DATA_DIR/server.conf" ]; then
cat > "$DATA_DIR/server.conf" << 'EOF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.webUIEnabled = true
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.webUIChannel = "PREVIEW"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
@@ -79,13 +79,13 @@ fi
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
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\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# 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\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
+11 -2
View File
@@ -2,9 +2,15 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Moku",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"core:tray:default",
"core:app:allow-default-window-icon",
"core:window:allow-hide",
"core:window:allow-show",
"shell:allow-open",
"shell:allow-kill",
"shell:allow-spawn",
@@ -27,6 +33,7 @@
"core:window:allow-outer-position",
"core:window:allow-scale-factor",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
@@ -36,6 +43,8 @@
"discord-rpc:allow-disconnect",
"discord-rpc:allow-set-activity",
"discord-rpc:allow-clear-activity",
"discord-rpc:allow-is-running"
"discord-rpc:allow-is-running",
"dialog:default",
"dialog:allow-open"
]
}
+3 -1
View File
@@ -77,7 +77,9 @@ pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(),
#[tauri::command]
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
backup_dir(&app).to_string_lossy().into_owned()
let dir = backup_dir(&app);
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
#[tauri::command]
+18 -8
View File
@@ -47,12 +47,22 @@ mod windows_hello {
}
pub fn authenticate(reason: &str) -> Result<(), String> {
nudge_focus(5, 250);
let result = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason))
.and_then(|op| {
nudge_focus(5, 250);
op.get()
})
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 {
@@ -77,9 +87,9 @@ mod windows_hello {
}
#[tauri::command]
pub fn windows_hello_authenticate(reason: String) -> Result<(), String> {
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
return windows_hello::authenticate(&reason);
return windows_hello::authenticate(&_reason);
#[cfg(not(target_os = "windows"))]
Err("notSupported".into())
}
+20 -3
View File
@@ -3,7 +3,12 @@ use crate::ServerState;
use tauri::Manager;
#[tauri::command]
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
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() {
@@ -20,12 +25,17 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
.open(&log_path)
.ok();
let binary_args = binary_args.unwrap_or_default();
server::do_log(
&mut log,
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
&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);
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| {
@@ -33,6 +43,13 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
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={}",
+48
View File
@@ -2,10 +2,58 @@ 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,
+99 -8
View File
@@ -1,7 +1,7 @@
#[cfg(target_os = "windows")]
use crate::server::resolve::strip_unc;
use tauri::Manager;
use std::path::PathBuf;
use tauri::Manager;
#[tauri::command]
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
@@ -52,19 +52,95 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
.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 fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
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() {
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
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(())
}
@@ -72,10 +148,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir();
for dir in &["cache", "bin/kcef", "cache/kcef"] {
for dir in &["cache/kcef", "logs"] {
let p = data_dir.join(dir);
if p.exists() {
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
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(())
@@ -86,10 +169,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app);
std::thread::sleep(std::time::Duration::from_millis(500));
let data_dir = suwayomi_data_dir();
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
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}"))?;
+117 -4
View File
@@ -2,16 +2,83 @@ mod commands;
mod server;
use std::sync::Mutex;
use tauri::{Manager, WindowEvent};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
};
use tauri_plugin_shell::process::CommandChild;
pub struct ServerState(pub Mutex<Option<CommandChild>>);
const IPC_PORT: u16 = 47823;
const HANDSHAKE: &[u8] = b"MOKU:1\n";
const FOCUS_CMD: &[u8] = b"focus\n";
fn do_quit(app: &tauri::AppHandle) {
server::kill_tachidesk(app);
app.exit(0);
}
fn start_instance_listener(app: tauri::AppHandle) {
std::thread::spawn(move || {
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
return;
};
for stream in listener.incoming().flatten() {
handle_ipc_connection(stream, &app);
}
});
}
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;
}
let cmd = &msg[HANDSHAKE.len()..];
if cmd.starts_with(b"focus") {
let _ = stream.write_all(b"ok\n");
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
}
fn signal_existing_instance() -> bool {
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
return false;
};
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
let mut msg = Vec::new();
msg.extend_from_slice(HANDSHAKE);
msg.extend_from_slice(FOCUS_CMD);
if stream.write_all(&msg).is_err() {
return false;
}
let mut resp = [0u8; 4];
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
if signal_existing_instance() {
std::process::exit(0);
}
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_discord_rpc::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
@@ -35,21 +102,67 @@ pub fn run() {
commands::system::reset_suwayomi_data,
commands::system::open_path,
commands::system::pick_downloads_folder,
commands::system::pick_server_binary,
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| {
if let WindowEvent::Destroyed = event {
server::kill_tachidesk(window.app_handle());
}
})
.run(tauri::generate_context!())
.expect("error while running moku");
.expect("error while running moku")
}
+13 -4
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
pub fn seed_server_conf(data_dir: &PathBuf) {
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
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;
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "true"),
patch_conf_key(
contents,
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
),
"server.initialOpenInBrowserEnabled",
"false",
),
+76 -174
View File
@@ -47,22 +47,14 @@ pub fn strip_unc(path: PathBuf) -> 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");
fn java_bin_name() -> &'static str {
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
}
do_log(
log,
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
);
if java.exists() {
Some(java)
} else {
None
}
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> {
@@ -73,24 +65,34 @@ 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));
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()),
);
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: path.parent().map(|p| p.to_path_buf()),
working_dir,
});
}
do_log(log, "[resolve] user path not found, falling through");
@@ -100,10 +102,7 @@ pub fn resolve_server_binary(
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()),
);
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
@@ -115,54 +114,31 @@ pub fn resolve_server_binary(
}
}
#[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
};
#[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()),
);
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
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_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir: Some(bundle_dir),
});
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));
}
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
}
for name in &[
"suwayomi-launcher",
"suwayomi-launcher.sh",
"tachidesk-server",
] {
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()),
);
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
if p.exists() {
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
@@ -173,26 +149,16 @@ pub fn resolve_server_binary(
}
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_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
working_dir: Some(resource_dir),
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));
}
}
}
@@ -200,108 +166,43 @@ pub fn resolve_server_binary(
#[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();
let bundle_dir = resource_dir.join("suwayomi-bundle");
do_log(
log,
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
);
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
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 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");
let mut found_binary: Option<ServerInvocation> = None;
let mut found_java: Option<(PathBuf, PathBuf)> = None;
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()));
'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: data_root_args(),
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 java.exists() && jar.exists() {
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
return Ok(jar_invocation(java, jar, bundle_dir));
}
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());
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: java.to_string_lossy().into_owned(),
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
working_dir,
bin: launcher_sh.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(bundle_dir),
});
}
do_log(log, "[resolve] macOS scan found nothing in bundle");
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"] {
@@ -313,6 +214,7 @@ pub fn resolve_server_binary(
.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)
+3 -2
View File
@@ -1,11 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.9.2",
"version": "0.9.4",
"identifier": "io.github.MokuProject.Moku",
"build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist",
"beforeBuildCommand": "pnpm build"
"beforeBuildCommand": "pnpm build:static"
},
"app": {
"windows": [
-1
View File
@@ -1,6 +1,5 @@
{
"build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev"
},
"app": {
-171
View File
@@ -1,171 +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, initStore, 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);
});
}
await initStore();
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}
<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.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}
{#if boot.sessionExpired}
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
<AuthGate onReady={() => { boot.sessionExpired = 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>
-86
View File
@@ -1,86 +0,0 @@
import { store } from "@store/state.svelte";
import { fetchAuthenticated, AuthRequiredError } from "../core/auth";
import { boot } from "@store/boot.svelte";
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, boot.skipped);
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 (e instanceof AuthRequiredError) throw e;
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) {
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
if (isAuthError && !boot.skipped) {
boot.sessionExpired = true;
boot.loginRequired = true;
boot.loginUser = store.settings.serverAuthUser ?? "";
throw new AuthRequiredError(json.errors[0].message);
}
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";
-5
View File
@@ -1,5 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
-130
View File
@@ -1,130 +0,0 @@
# Mutations
## Manga (`mutations/manga.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
| `UPDATE_STOP` | — | Stop the currently running library update job |
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
---
## Chapters (`mutations/chapters.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
---
## Downloads (`mutations/downloads.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
| `START_DOWNLOADER` | — | Start the downloader |
| `STOP_DOWNLOADER` | — | Stop the downloader |
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
---
## Extensions (`mutations/extensions.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
---
## Tracking (`mutations/tracking.ts`)
| Mutation | Variables | Description |
|----------|-----------|-------------|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
| `REFRESH_TOKEN` | — | Refresh the current access token |
---
## New in Preview
Mutations now available and not yet wired to any feature in Moku:
| Mutation | Potential Feature |
|----------|-------------------|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
| `UPDATE_STOP` | Cancel button for library update jobs |
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
| `RESET_SETTINGS` | Settings page — factory reset button |
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
-122
View File
@@ -1,122 +0,0 @@
const TRACK_RECORD_FRAGMENT = `
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
`;
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 libraryId
}
}
}
`;
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 libraryId
}
}
}
`;
export const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords {
id trackerId lastChapterRead status
}
}
}
`;
export const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
tracker { id name isLoggedIn isTokenExpired 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 isTokenExpired authUrl }
}
}
`;
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
tracker { id name isLoggedIn isTokenExpired authUrl }
}
}
`;
export const CONNECT_KOSYNC = `
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
isConnected
}
}
`;
export const LOGOUT_KOSYNC = `
mutation LogoutKoSync {
logoutKoSyncAccount(input: {}) {
isConnected
}
}
`;
export const PULL_KOSYNC_PROGRESS = `
mutation PullKoSyncProgress($chapterId: Int!) {
pullKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const PUSH_KOSYNC_PROGRESS = `
mutation PushKoSyncProgress($chapterId: Int!) {
pushKoSyncProgress(input: { chapterId: $chapterId }) {
chapter { id lastPageRead isRead }
}
}
`;
export const LOGIN_USER = `
mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) {
accessToken
}
}
`;
export const REFRESH_TOKEN = `
mutation RefreshToken {
refreshToken(input: {}) { 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 lastReadAt scanlator
}
}
}
`;
-14
View File
@@ -1,14 +0,0 @@
export const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`;
-46
View File
@@ -1,46 +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
isConfigurable supportsLatest baseUrl
}
}
}
`;
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
}
}
`;
-7
View File
@@ -1,7 +0,0 @@
export * from "./manga";
export * from "./chapters";
export * from "./downloads";
export * from "./extensions";
export * from "./tracking";
export * from "./updater";
export * from "./meta";
-107
View File
@@ -1,107 +0,0 @@
export const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
source { id name displayName }
chapters { totalCount }
latestFetchedChapter { id uploadDate }
latestUploadedChapter { id uploadDate }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
}
}
`;
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
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
}
}
`;
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 skippedCategoriesCount
}
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
}
}
`;
-15
View File
@@ -1,15 +0,0 @@
export const GET_META = `
query GetMeta($key: String!) {
meta(key: $key) {
key value
}
}
`;
export const GET_METAS = `
query GetMetas {
metas {
nodes { key value }
}
}
`;
-117
View File
@@ -1,117 +0,0 @@
# Queries
## Manga (`queries/manga.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
---
## Chapters (`queries/chapters.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
---
## Downloads (`queries/downloads.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
---
## Extensions (`queries/extensions.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
| `GET_SETTINGS` | — | `extensionRepos` from settings |
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
---
## Tracking (`queries/tracking.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
---
## Updater (`queries/updater.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
---
## Meta (`queries/meta.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
| `GET_METAS` | — | All global meta entries as a node list |
---
## KoSync (`queries/kosync.ts`)
| Query | Variables | Description |
|-------|-----------|-------------|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
---
## New in Preview
Queries and fields now available but not yet wired to any feature in Moku:
| Query / Field | Potential Feature |
|---------------|-------------------|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
| `category` (single by id) | Direct category detail without fetching all categories |
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
| `source` (single by id) | Source detail page — preferences, filters, browse |
| `tracker` (single by id) | Individual tracker detail — statuses, records |
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
-71
View File
@@ -1,71 +0,0 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
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 libraryId
}
}
}
}
`;
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 isTokenExpired scores
statuses { value name }
trackRecords {
nodes {
id trackerId title status displayScore lastChapterRead
totalChapters remoteUrl private libraryId
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 }
}
}
}
}
}
`;
-23
View File
@@ -1,23 +0,0 @@
export const GET_ABOUT_SERVER = `
query GetAboutServer {
aboutServer {
name version buildType buildTime github discord
}
}
`;
export const GET_ABOUT_WEBUI = `
query GetAboutWebUI {
aboutWebUI {
channel tag updateTimestamp
}
}
`;
export const CHECK_FOR_SERVER_UPDATES = `
query CheckForServerUpdates {
checkForServerUpdates {
channel tag url
}
}
`;
+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();
};
};
}
-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';
-150
View File
@@ -1,150 +0,0 @@
import { store, updateSettings } from "@store/state.svelte";
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
export class AuthRequiredError extends Error {
constructor(msg = "Authentication required") {
super(msg);
this.name = "AuthRequiredError";
}
}
let _accessToken: string | null = null;
export const uiAuth = {
getToken: () => _accessToken,
setToken: (t: string) => { _accessToken = t; },
clearToken: () => { _accessToken = null; },
};
export const authSession = {
clearTokens() { uiAuth.clearToken(); },
hasSession(): boolean {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") return _accessToken !== null;
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}`)}` };
}
function bearerHeader(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function gqlBody(query: string, variables?: Record<string, unknown>): string {
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
}
export async function fetchAuthenticated(
url: string,
init: RequestInit,
signal?: AbortSignal,
skipped = false,
): Promise<Response> {
const mode = store.settings.serverAuthMode ?? "NONE";
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
});
}
if (mode === "UI_LOGIN") {
const token = uiAuth.getToken();
if (!token) {
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
throw new AuthRequiredError();
}
return fetch(url, {
...init, signal, credentials: "omit",
headers: { ...baseHeaders, ...bearerHeader(token) },
});
}
return fetch(url, { ...init, signal, credentials: "omit" });
}
export async function loginUI(user: string, pass: string): Promise<void> {
const res = await fetch(`${getServerBase()}/api/graphql`, {
method: "POST", credentials: "omit",
headers: { "Content-Type": "application/json" },
body: gqlBody(
`mutation Login($username: String!, $password: String!) {
login(input: { username: $username, password: $password }) { accessToken }
}`,
{ username: user, password: pass },
),
signal: timeoutSignal(8000),
});
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
const json = await res.json();
const token: string | undefined = json?.data?.login?.accessToken;
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
uiAuth.setToken(token);
updateSettings({ serverAuthMode: "UI_LOGIN" });
}
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: gqlBody("{ __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> {
uiAuth.clearToken();
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
}
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
const base = getServerBase();
const mode = store.settings.serverAuthMode ?? "NONE";
const s = store.settings;
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
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));
} else if (mode === "UI_LOGIN" && _accessToken) {
Object.assign(headers, bearerHeader(_accessToken));
}
const res = await fetch(`${base}/api/graphql`, {
method: "POST", credentials: "omit", headers,
body: gqlBody("{ __typename }"),
signal: timeoutSignal(5000),
});
if (res.ok) return "ok";
if (res.status === 401) return "auth_required";
return "unreachable";
} catch {
return "unreachable";
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
View File
-27
View File
@@ -1,27 +0,0 @@
import { store, linkManga } from "@store/state.svelte";
import type { Manga } from "@types";
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise((resolve) => {
const worker = new Worker(
new URL("./autoLinkWorker.ts", import.meta.url),
{ type: "module" },
);
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data;
for (const id of matches) linkManga(focal.id, id);
worker.terminate();
resolve(matches.length);
};
worker.onerror = () => { worker.terminate(); resolve(0); };
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
});
});
}
-29
View File
@@ -1,29 +0,0 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wa = new Set(norm(a));
const wb = new Set(norm(b));
if (!wa.size || !wb.size) return 0;
const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
}
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data;
const matches: number[] = [];
for (const m of allManga) {
if (m.id === focalId) continue;
if (linkedIds.includes(m.id)) continue;
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
}
self.postMessage(matches);
};
-54
View File
@@ -1,54 +0,0 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels);
for (let i = 0; i < pixels; i++) {
const o = i * 4;
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
}
return gray;
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = THUMB_SIZE;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
};
img.onerror = reject;
img.src = url;
});
}
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / (a.length * 255);
}
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!;
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
} catch {
return null;
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
}
export function clearHashCache(): void {
hashCache.clear();
}
-95
View File
@@ -1,95 +0,0 @@
import { store } from "@store/state.svelte";
import { searchWithScore } from "@core/algorithms/search";
import { getHash, areDuplicates } from "@core/cover/coverHash";
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
export type CoverCandidate = {
mangaId: number;
url: string;
label: string;
isActive: boolean;
};
const FUZZY_SCORE_THRESHOLD = 0.65;
function normalizeUrl(url: string): string {
try {
const u = new URL(url);
u.search = "";
return u.href.toLowerCase();
} catch {
return url.toLowerCase();
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
const results = searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
);
return results
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id);
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = store.getLinkedMangaIds(mangaId);
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: "This source" },
...allIds.flatMap(id => {
const m = mangaById.get(id);
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
}),
];
const seen = new Set<string>();
return raw
.filter(c => {
const key = normalizeUrl(c.url);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
const groups: number[][] = [];
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i];
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
: undefined;
if (existing) existing.push(i);
else groups.push([i]);
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0];
const labels = [...new Set(group.map(i => candidates[i].label))];
return { ...candidates[active], label: labels.join(" · ") };
});
}
-4
View File
@@ -1,4 +0,0 @@
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
export type { CoverCandidate } from "./coverResolver";
export { autoLinkLibrary } from "./autoLink";
-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";
-88
View File
@@ -1,88 +0,0 @@
const VAULT_KEY = "moku-credential-vault";
const SALT_ITERATIONS = 200_000;
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
export interface VaultPayload {
refreshToken?: string;
basicUser?: string;
basicPass?: string;
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
}
interface StoredVault {
salt: string;
iv: string;
data: string;
}
function toB64(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function fromB64(s: string): Uint8Array {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
}
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
keyMat,
{ name: "AES-GCM", length: 256 },
false,
KEY_USAGE,
);
}
export function vaultExists(): boolean {
return !!localStorage.getItem(VAULT_KEY);
}
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pin, salt);
const enc = new TextEncoder();
const cipher = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
enc.encode(JSON.stringify(payload)),
);
localStorage.setItem(VAULT_KEY, JSON.stringify({
salt: toB64(salt),
iv: toB64(iv),
data: toB64(cipher),
} satisfies StoredVault));
}
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
const raw = localStorage.getItem(VAULT_KEY);
if (!raw) return null;
try {
const stored = JSON.parse(raw) as StoredVault;
const key = await deriveKey(pin, fromB64(stored.salt));
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(stored.iv) },
key,
fromB64(stored.data),
);
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
} catch {
return null;
}
}
export function clearVault(): void {
localStorage.removeItem(VAULT_KEY);
}
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
const payload = await unlockVault(oldPin);
if (!payload) return false;
await lockVault(newPin, payload);
return true;
}
-5
View File
@@ -1,5 +0,0 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
-166
View File
@@ -1,166 +0,0 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
-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));
};
}
-3
View File
@@ -1,3 +0,0 @@
export * from './idle';
export * from './zoom';
export * from './touchscreen';
-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;
}
-35
View File
@@ -1,35 +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);
}
-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 {
}
-13
View File
@@ -1,13 +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;
--titlebar-height: 36px;
}
-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";
@@ -1,72 +0,0 @@
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
return queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}));
}
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
return queue.filter((i) => i.chapter.id !== chapterId);
}
export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set<number>): DownloadQueueItem[] {
return queue.filter((i) => !chapterIds.has(i.chapter.id));
}
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
return queue.filter((i) => i.state === "ERROR");
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
export interface SpeedSample {
ts: number;
progress: number;
pages: number;
}
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
if (!prev) return null;
const dt = (current.ts - prev.ts) / 1000;
if (dt <= 0) return null;
const prevDone = Math.round(prev.progress * prev.pages);
const curDone = Math.round(current.progress * current.pages);
const delta = curDone - prevDone;
if (delta <= 0) return null;
return delta / dt;
}
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
if (pagesPerSec <= 0 || queue.length === 0) return null;
let remaining = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
remaining += pages - Math.round(item.progress * pages);
}
return remaining / pagesPerSec;
}
export function reorderSelectedToEdge(
queue: DownloadQueueItem[],
selected: Set<number>,
edge: "top" | "bottom",
): DownloadQueueItem[] {
const pinned = queue.filter((i) => selected.has(i.chapter.id));
const rest = queue.filter((i) => !selected.has(i.chapter.id));
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
}
export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
return `~${(seconds / 3600).toFixed(1)}h`;
}
@@ -1,342 +0,0 @@
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries";
import {
START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER,
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
} from "@api/mutations";
import { addToast, setActiveDownloads } from "@store/state.svelte";
import { boot } from "@store/boot.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta,
type SpeedSample,
} from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
toastsEnabled = $state(true);
autoRetryEnabled = $state(false);
private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() {
this.toastsEnabled = !this.toastsEnabled;
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
this.autoRetryEnabled = false;
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else {
this.autoRetryEnabled = true;
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
}
}
detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return;
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
for (const item of this.prevQueue) {
if (item.state !== "DOWNLOADING") continue;
const nextItem = nextMap.get(item.chapter.id);
const manga = item.chapter.manga;
const label = manga ? `${manga.title}${item.chapter.name}` : item.chapter.name;
if (!nextItem) {
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
} else if (nextItem.state === "ERROR") {
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
}
}
this.prevQueue = next.slice();
}
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
this.updateSpeed(ds);
}
private updateSpeed(ds: DownloadStatus) {
const active = ds.queue[0];
if (!active || active.state !== "DOWNLOADING") {
this.lastSample = null;
this.pagesPerSec = null;
this.eta = null;
return;
}
const sample: SpeedSample = {
ts: Date.now(),
progress: active.progress,
pages: active.chapter.pageCount ?? 0,
};
const speed = calcSpeed(this.lastSample, sample);
this.lastSample = sample;
if (speed !== null) {
this.pagesPerSec = speed;
this.eta = estimateEta(speed, ds.queue);
}
}
async poll() {
if (boot.sessionExpired) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => { this.loading = false; });
}
async togglePlay() {
if (this.togglingPlay) return;
this.togglingPlay = true;
const wasRunning = this.isRunning;
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
this.applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
this.applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); this.poll(); }
finally {
this.togglingPlay = false;
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
}
}
async clear() {
if (this.clearing) return;
this.clearing = true;
this.selected = new Set();
if (this.status) this.status = { ...this.status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus);
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; }
}
async dequeue(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) };
this.selected.delete(chapterId);
this.selected = new Set(this.selected);
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async dequeueSelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected];
if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) };
this.selected = new Set();
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
this.poll();
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
try {
await gql(DEQUEUE_DOWNLOAD, { chapterId });
await gql(ENQUEUE_DOWNLOAD, { chapterId });
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
async retryAllErrored() {
if (this.batchWorking || !this.hasErrored) return;
this.batchWorking = true;
const ids = [...this.erroredIds];
try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async retrySelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
this.selected = new Set();
try {
if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorder(chapterId: number, direction: "up" | "down") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= this.queue.length) return;
const newQueue = [...this.queue];
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelected(direction: "up" | "down") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const queue = [...this.queue];
const selectedIndices = queue
.map((item, i) => ({ id: item.chapter.id, i }))
.filter(({ id }) => this.selected.has(id))
.map(({ i }) => i)
.sort((a, b) => direction === "up" ? a - b : b - a);
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
const newQueue = [...queue];
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= newQueue.length) break;
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
}
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
for (const idx of selectedIndices) {
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= queue.length) break;
const chapterId = queue[idx].chapter.id;
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex((i) => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId, to },
);
this.applyStatus(d.reorderChapterDownload.downloadStatus);
} catch (e) { console.error(e); this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const first = this.isRunning ? 1 : 0;
const active = this.queue.slice(0, first);
const moveable = this.queue.slice(first);
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
const newQueue = edge === "top"
? [...active, ...pinned, ...rest]
: [...active, ...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
);
}
} else {
for (let i = 0; i < pinned.length; i++) {
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
);
}
}
this.poll();
} catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) {
const next = new Set(this.selected);
if (next.has(chapterId)) next.delete(chapterId);
else next.add(chapterId);
this.selected = next;
}
selectRange(fromId: number, toId: number) {
const ids = this.queue.map((i) => i.chapter.id);
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
if (a === -1 || b === -1) return;
const [lo, hi] = a < b ? [a, b] : [b, a];
const next = new Set(this.selected);
for (let i = lo; i <= hi; i++) next.add(ids[i]);
this.selected = next;
}
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
clearSelection() { this.selected = new Set(); }
}
export const downloadStore = new DownloadStore();

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