Compare commits
362 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c703bdba5 | |||
| a041b182e5 | |||
| 9dad1fb329 | |||
| 31a19687ce | |||
| 437b52fd8b | |||
| 1e159bbd73 | |||
| cb3d8d64fa | |||
| c0a95ff899 | |||
| ddaca9d126 | |||
| 77b28e97a4 | |||
| f10b343108 | |||
| a8ad9034fc | |||
| f99fa60e8e | |||
| 915ff66b2f | |||
| abd60f261f | |||
| fc20835dde | |||
| 04631d93ef | |||
| 5c09cd15ad | |||
| 7af69fd77c | |||
| 0b6372bd17 | |||
| 32bdeb92ff | |||
| 22c4a222d8 | |||
| 26cb16ec0f | |||
| 8c2917b698 | |||
| 6d33fb7ae1 | |||
| 0e7ff1a27c | |||
| 685bd9b9da | |||
| 3926b5d064 | |||
| 9f6996dcdb | |||
| 294865fe9d | |||
| 13e760594d | |||
| b44b12ba86 | |||
| 3b8c8dea38 | |||
| 615fa1e92f | |||
| 248b046627 | |||
| 79e5548879 | |||
| ed4c11ca7e | |||
| 5dfbc80bbe | |||
| 8aa92e6b54 | |||
| b55dd16d0d | |||
| 7c6aeb8f4c | |||
| 3e4d322fb7 | |||
| db8a984270 | |||
| 18027baee1 | |||
| c5243ba30c | |||
| 13f2a483ca | |||
| 6de5207ce7 | |||
| 8c250021a0 | |||
| 584b917f98 | |||
| e9929747d2 | |||
| cbdf9e8be1 | |||
| d9a9427e3b | |||
| ae5d9748c7 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bee8117aac | |||
| 0bea9c22cb | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e | |||
| 83711c155d | |||
| 3702a25813 | |||
| a71cc719ba | |||
| 1801fecdbb | |||
| 0cd799f450 | |||
| 5dab7761bc | |||
| 552a11a517 | |||
| c8ec6d6b90 | |||
| daaeae00fe | |||
| 79cb2f7c56 | |||
| 4d3dfdbec6 | |||
| 78573eacb1 | |||
| 1bb7da3b22 | |||
| dd0cf9372d | |||
| 50928c6343 | |||
| 170493aa71 | |||
| c009bd71fc | |||
| 4df7f416a7 | |||
| 63209cb828 | |||
| 2c1391c378 | |||
| 41fd4a820c | |||
| 9f3c6d2ac3 | |||
| f5b3f76b5d | |||
| 528f966b1f | |||
| 75e8bc5986 | |||
| 8123053a40 | |||
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 | |||
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 |
@@ -0,0 +1,78 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working as expected
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. The more detail you include, the faster it gets fixed.
|
||||
You can use the **Report a Bug** button in **Settings → About** to pre-fill most of this automatically.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What's broken? A clear, concise summary.
|
||||
placeholder: "e.g. Library card stats don't appear even with 'Always show' enabled"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Exact steps to trigger the bug.
|
||||
placeholder: |
|
||||
1. Open Settings → Library
|
||||
2. Enable "Always show card stats"
|
||||
3. Return to Library
|
||||
4. Unread counts are not visible
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: "Unread and download counts should be permanently visible on manga cards"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: "Counts only appear on hover, or not at all"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Copy this from Settings → About → Report a Bug, or fill in manually.
|
||||
placeholder: |
|
||||
- Moku Version: v0.9.4
|
||||
- Platform: Windows / macOS / Linux / Web
|
||||
- OS Version: Windows 11 24H2
|
||||
- Server: Suwayomi v2.2.2196
|
||||
- Server URL: localhost:4567
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: settings
|
||||
attributes:
|
||||
label: Relevant Settings
|
||||
description: Settings related to the bug (auto-filled by the in-app reporter, or paste manually).
|
||||
placeholder: |
|
||||
libraryStatsAlways: true
|
||||
libraryCropCovers: true
|
||||
libraryPageSize: 48
|
||||
render: yaml
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Screenshots, screen recordings, console errors, anything else helpful.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions (Questions & Support)
|
||||
url: https://github.com/moku-project/Moku/discussions
|
||||
about: Not a bug? Ask questions and get help here.
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Got an idea? Describe what you want and why it would be useful.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: What's the gap or frustration this would address?
|
||||
placeholder: "e.g. There's no way to bulk-mark chapters as read without opening each series"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: What would you like to see?
|
||||
placeholder: "A 'Mark all read' option in the series long-press context menu"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any workarounds you've tried, or other ways this could be solved.
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Optional — useful if this is platform-specific.
|
||||
placeholder: |
|
||||
- Moku Version: v0.9.4
|
||||
- Platform: Windows / macOS / Linux / Web
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Mockups, references, examples from other apps, etc.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sourced by CI jobs that need versions from nix/versions.nix.
|
||||
# Usage: source .github/read_versions.sh
|
||||
# Exports: MOKU_VERSION SUWA_VERSION SUWA_HASH_LINUX SUWA_HASH_MACOS_ARM64 SUWA_HASH_MACOS_X64 SUWA_HASH_WINDOWS
|
||||
|
||||
_nix="$( cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd )/nix/versions.nix"
|
||||
_t=$(cat "$_nix")
|
||||
|
||||
_pick() { echo "$_t" | grep -oP "${1}\s*=\s*\"\K[^\"]+"; }
|
||||
|
||||
export MOKU_VERSION=$(_pick "moku")
|
||||
export SUWA_VERSION=$(_pick "version")
|
||||
export SUWA_HASH_WINDOWS=$(_pick "windowsHash")
|
||||
export SUWA_HASH_LINUX=$(_pick "linuxHash")
|
||||
export SUWA_HASH_MACOS_ARM64=$(_pick "macosArm64Hash")
|
||||
export SUWA_HASH_MACOS_X64=$(_pick "macosX64Hash")
|
||||
|
||||
unset _nix _t
|
||||
unset -f _pick
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Build Flatpak
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
flatpak:
|
||||
name: Build Flatpak bundle
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/dotnet /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune -af || true
|
||||
|
||||
- name: Install flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Cache flatpak runtimes/SDKs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/flatpak
|
||||
key: flatpak-runtimes-gnome48-rust-stable
|
||||
|
||||
- name: Install runtime, SDK and rust-stable extension
|
||||
run: |
|
||||
flatpak --user install -y --noninteractive flathub \
|
||||
org.gnome.Platform//48 \
|
||||
org.gnome.Sdk//48 \
|
||||
org.freedesktop.Sdk.Extension.rust-stable//48
|
||||
|
||||
- name: Build flatpak
|
||||
run: |
|
||||
rm -rf build-dir repo
|
||||
flatpak-builder \
|
||||
--user \
|
||||
--install-deps-from=flathub \
|
||||
--repo=repo \
|
||||
--force-clean \
|
||||
build-dir \
|
||||
io.github.moku_project.Moku.yml
|
||||
|
||||
- name: Bundle flatpak
|
||||
run: |
|
||||
flatpak build-bundle \
|
||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo \
|
||||
repo \
|
||||
moku.flatpak \
|
||||
io.github.moku_project.Moku
|
||||
|
||||
- name: Upload Flatpak artifact to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"moku.flatpak" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku.flatpak"
|
||||
@@ -0,0 +1,128 @@
|
||||
name: Build Linux
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-linux
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
tauri:
|
||||
name: Tauri (Linux x64)
|
||||
needs: frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist-linux, path: dist/ }
|
||||
|
||||
- name: Read versions
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "MOKU_VERSION=$MOKU_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH=$SUWA_HASH_LINUX" >> $GITHUB_ENV
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-gnu }
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi (Linux x64)
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-linux-x64.tar.gz" \
|
||||
-o suwayomi-linux.tar.gz
|
||||
echo "${SUWA_HASH} suwayomi-linux.tar.gz" | sha256sum -c -
|
||||
mkdir -p suwayomi-extracted
|
||||
tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
for f in suwayomi-extracted/bin/Suwayomi-Server.jar \
|
||||
suwayomi-extracted/jre/bin/java \
|
||||
suwayomi-extracted/bin/catch_abort.so; do
|
||||
[ -e "$f" ] || { echo "ERROR: missing $f"; find suwayomi-extracted -type f | head -40; exit 1; }
|
||||
done
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
chmod +x src-tauri/binaries/suwayomi-bundle/jre/bin/java
|
||||
|
||||
- name: Stage Linux launcher sidecar
|
||||
run: |
|
||||
cp src-tauri/binaries/suwayomi-launcher-linux.sh \
|
||||
src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||
chmod +x src-tauri/binaries/suwayomi-launcher-linux-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
run: |
|
||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Build Tauri app
|
||||
run: pnpm tauri build --target x86_64-unknown-linux-gnu --config src-tauri/tauri.linux.conf.json --verbose
|
||||
env: { NO_STRIP: "true" }
|
||||
|
||||
- name: Upload Linux artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
upload() {
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$1" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||
}
|
||||
|
||||
APPIMAGE=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
DEB=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb -name "*.deb" | head -1)
|
||||
[ -n "$APPIMAGE" ] && upload "$APPIMAGE" "moku-linux-x64-${{ github.event.inputs.version }}.AppImage"
|
||||
[ -n "$DEB" ] && upload "$DEB" "moku-linux-x64-${{ github.event.inputs.version }}.deb"
|
||||
@@ -4,245 +4,133 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.3.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: frontend-dist, path: dist/, retention-days: 1 }
|
||||
|
||||
tauri:
|
||||
name: Tauri (macOS)
|
||||
needs: frontend
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist, path: dist/ }
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
- name: Read versions
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH_ARM64=$SUWA_HASH_MACOS_ARM64" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH_X64=$SUWA_HASH_MACOS_X64" >> $GITHUB_ENV
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
targets: "aarch64-apple-darwin,x86_64-apple-darwin"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi binaries
|
||||
run: |
|
||||
download_suwayomi() {
|
||||
dl() {
|
||||
local asset="$1" sha="$2" outdir="$3"
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
|
||||
-o "${outdir}.tar.gz"
|
||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||
mkdir -p "${outdir}"
|
||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||
}
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
||||
"suwayomi-arm64"
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
||||
"suwayomi-x64"
|
||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
|
||||
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
||||
|
||||
- name: Stage Suwayomi sidecars
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
find_launcher() {
|
||||
local dir="$1"
|
||||
# v2.1.1867 macOS tarball ships "Suwayomi Launcher.command" (space, .command)
|
||||
find "$dir" -maxdepth 1 -type f -name "*.command" | head -1
|
||||
stage() {
|
||||
local srcdir="$1" arch="$2"
|
||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||
[ -z "$JAR" ] && { echo "ERROR: jar not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
||||
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
||||
}
|
||||
|
||||
ARM_LAUNCHER=$(find_launcher suwayomi-arm64)
|
||||
X64_LAUNCHER=$(find_launcher suwayomi-x64)
|
||||
|
||||
if [ -z "$ARM_LAUNCHER" ] || [ -z "$X64_LAUNCHER" ]; then
|
||||
echo "ERROR: could not find launchers — tarball contents:"
|
||||
ls -lR suwayomi-arm64 suwayomi-x64
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "arm64 launcher: $ARM_LAUNCHER"
|
||||
echo "x64 launcher: $X64_LAUNCHER"
|
||||
|
||||
cp "$ARM_LAUNCHER" src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||
cp "$X64_LAUNCHER" src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||
chmod +x src-tauri/binaries/suwayomi-server-aarch64-apple-darwin
|
||||
chmod +x src-tauri/binaries/suwayomi-server-x86_64-apple-darwin
|
||||
|
||||
# tauri.conf.json expects exactly "binaries/suwayomi-bundle".
|
||||
# We stage both arch bundles and swap the symlink before each build.
|
||||
cp -r suwayomi-arm64 src-tauri/binaries/suwayomi-bundle-arm64
|
||||
cp -r suwayomi-x64 src-tauri/binaries/suwayomi-bundle-x64
|
||||
stage suwayomi-arm64 aarch64-apple-darwin
|
||||
stage suwayomi-x64 x86_64-apple-darwin
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
run: |
|
||||
# dist/ is already built by the frontend job — suppress the rebuild.
|
||||
# We patch in-place rather than using --config to avoid Tauri schema issues.
|
||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Swap bundle for aarch64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-arm64 src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Build Tauri app (aarch64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
with:
|
||||
args: --target aarch64-apple-darwin
|
||||
|
||||
- name: Swap bundle for x86_64
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x64 src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||
pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
- name: Build Tauri app (x86_64)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
run: |
|
||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||
pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
|
||||
- name: Upload macOS artifacts to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ad-hoc signing by default ("-"); override by setting APPLE_SIGNING_IDENTITY secret.
|
||||
# Only set APPLE_CERTIFICATE/APPLE_ID/etc when you have a real Developer ID cert.
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||
with:
|
||||
args: --target x86_64-apple-darwin
|
||||
|
||||
- name: Upload arm64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-aarch64
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload x64 .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-x86_64
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload arm64 .app (for universal job)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-aarch64-apple-darwin
|
||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/macos/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload x64 .app (for universal job)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-x86_64-apple-darwin
|
||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/macos/
|
||||
retention-days: 1
|
||||
|
||||
universal:
|
||||
name: Universal .dmg
|
||||
needs: tauri
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Download arm64 .app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-aarch64-apple-darwin
|
||||
path: apps/arm64/
|
||||
|
||||
- name: Download x64 .app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: app-x86_64-apple-darwin
|
||||
path: apps/x64/
|
||||
|
||||
- name: lipo into universal binary
|
||||
run: |
|
||||
ARM_APP=$(find apps/arm64 -name "*.app" -maxdepth 1 | head -1)
|
||||
X64_APP=$(find apps/x64 -name "*.app" -maxdepth 1 | head -1)
|
||||
APP_NAME=$(basename "$ARM_APP")
|
||||
|
||||
mkdir -p universal
|
||||
cp -r "$ARM_APP" "universal/${APP_NAME}"
|
||||
|
||||
find "universal/${APP_NAME}" -type f | while read -r f; do
|
||||
if file "$f" | grep -q "Mach-O"; then
|
||||
X64_EQUIV="${X64_APP}${f#universal/${APP_NAME}}"
|
||||
if [ -f "$X64_EQUIV" ]; then
|
||||
lipo -create -output "$f" "$f" "$X64_EQUIV" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
- name: Package universal .dmg
|
||||
run: |
|
||||
APP_NAME=$(find universal -name "*.app" -maxdepth 1 | head -1 | xargs basename)
|
||||
mkdir dmg-stage
|
||||
cp -r "universal/${APP_NAME}" dmg-stage/
|
||||
ln -s /Applications dmg-stage/Applications
|
||||
hdiutil create \
|
||||
-volname "Moku" \
|
||||
-srcfolder dmg-stage \
|
||||
-ov -format UDZO \
|
||||
"moku-universal.dmg"
|
||||
upload() {
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$1" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||
}
|
||||
|
||||
- name: Upload universal .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-universal
|
||||
path: moku-universal.dmg
|
||||
retention-days: 7
|
||||
ARM64=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
X64=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
[ -n "$ARM64" ] && upload "$ARM64" "moku-macos-arm64-${{ github.event.inputs.version }}.dmg"
|
||||
[ -n "$X64" ] && upload "$X64" "moku-macos-x64-${{ github.event.inputs.version }}.dmg"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Build Static WebUI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build static frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
|
||||
- name: Zip static build
|
||||
run: |
|
||||
cd dist
|
||||
zip -r "../moku-webui-${{ github.event.inputs.version }}.zip" .
|
||||
|
||||
- name: Upload WebUI artifact to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for i in $(seq 1 12); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}") | .id' | head -1)
|
||||
[ -n "$RELEASE_ID" ] && break
|
||||
echo "Waiting for release... attempt $i"; sleep 15
|
||||
done
|
||||
[ -z "$RELEASE_ID" ] && { echo "ERROR: release not found"; exit 1; }
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"moku-webui-${{ github.event.inputs.version }}.zip" \
|
||||
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=moku-webui-${{ github.event.inputs.version }}.zip"
|
||||
@@ -4,132 +4,85 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.3.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:static
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
|
||||
|
||||
tauri:
|
||||
name: Tauri (Windows x64)
|
||||
needs: frontend
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-windows
|
||||
path: dist/
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: frontend-dist-windows, path: dist/ }
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- name: Read versions
|
||||
shell: bash
|
||||
run: |
|
||||
source .github/read_versions.sh
|
||||
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-pc-windows-msvc }
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { workspaces: src-tauri }
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
with: { version: latest }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download Suwayomi (Windows x64)
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-extracted-raw
|
||||
|
||||
# Detect whether the zip has a single top-level directory wrapper.
|
||||
# If exactly one entry exists at depth-1 and it is a directory, strip it.
|
||||
TOP_DIRS=$(find suwayomi-extracted-raw -mindepth 1 -maxdepth 1 -type d)
|
||||
TOP_FILES=$(find suwayomi-extracted-raw -mindepth 1 -maxdepth 1 -type f)
|
||||
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true)
|
||||
|
||||
- name: Stage Suwayomi bundle
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p suwayomi-extracted
|
||||
if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then
|
||||
# Single wrapping directory — strip it
|
||||
mv "$TOP_DIRS"/* suwayomi-extracted/
|
||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
||||
else
|
||||
# Files already at root level — move everything as-is
|
||||
mv suwayomi-extracted-raw/* suwayomi-extracted/
|
||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||
fi
|
||||
|
||||
- name: Inspect Suwayomi launcher
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Top-level contents ==="
|
||||
ls -la suwayomi-extracted/
|
||||
echo "=== All .exe files ==="
|
||||
find suwayomi-extracted -name "*.exe" | head -20
|
||||
echo "=== All .cmd/.bat files ==="
|
||||
find suwayomi-extracted -name "*.cmd" -o -name "*.bat" | head -20
|
||||
|
||||
- name: Stage Suwayomi sidecar
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
|
||||
# The Windows bundle has no standalone launcher exe — it ships
|
||||
# "Suwayomi Launcher.bat" which calls jre\bin\javaw.exe -jar Suwayomi-Launcher.jar.
|
||||
# Tauri sidecars must be real executables, so we stage javaw.exe as the
|
||||
# sidecar. lib.rs will invoke it with the correct -jar + working-dir args.
|
||||
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1)
|
||||
|
||||
if [ -z "$JAVAW" ]; then
|
||||
echo "ERROR: could not find jre/bin/javaw.exe"
|
||||
ls -lR suwayomi-extracted/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using javaw: $JAVAW"
|
||||
cp "$JAVAW" "src-tauri/binaries/suwayomi-server-x86_64-pc-windows-msvc.exe"
|
||||
|
||||
# Copy the full bundle so the .jar and jre/ tree are available at runtime.
|
||||
# lib.rs sets the working directory to this folder before spawning.
|
||||
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
||||
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
||||
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||
|
||||
- name: Patch tauri.conf.json for CI
|
||||
@@ -137,16 +90,35 @@ jobs:
|
||||
run: |
|
||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Build Tauri app (Windows x64)
|
||||
- name: Delete existing draft release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Tauri app + create draft release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: --target x86_64-pc-windows-msvc
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
releaseBody: |
|
||||
Moku v${{ github.event.inputs.version }}
|
||||
|
||||
- name: Upload Windows installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moku-windows-x64
|
||||
path: src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
retention-days: 7
|
||||
**Windows:** `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||
**macOS arm64:** `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||
**macOS x64:** `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||
**Linux:** `moku.flatpak`
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
|
||||
@@ -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,12 +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/
|
||||
./flatpak-builder
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright [2026] [@Youwes09]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pkgname=moku
|
||||
pkgver=0.3.0
|
||||
pkgver=0.10.0
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Youwes09/Moku"
|
||||
license=('Apache 2.0')
|
||||
url="https://github.com/moku-project/Moku"
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
@@ -13,34 +13,46 @@ depends=(
|
||||
)
|
||||
makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
'nodejs'
|
||||
'pnpm'
|
||||
)
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||
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=(
|
||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||
)
|
||||
b2sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
|
||||
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"
|
||||
|
||||
# Build frontend
|
||||
pnpm build
|
||||
|
||||
# Repack dist for Tauri
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
|
||||
# Build Tauri binary
|
||||
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
|
||||
@@ -49,21 +61,14 @@ build() {
|
||||
package() {
|
||||
cd "Moku-$pkgver"
|
||||
|
||||
# Moku binary
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
# Bundled JRE
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
# Suwayomi server jar
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
# tachidesk-server wrapper script
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
@@ -74,11 +79,11 @@ server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
CONF
|
||||
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||
#!/bin/sh
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
@@ -100,26 +105,25 @@ unset WAYLAND_DISPLAY
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
exec /usr/lib/moku/jre/bin/java \
|
||||
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
|
||||
EOF
|
||||
LAUNCHER
|
||||
|
||||
# Desktop entry and icons
|
||||
install -Dm644 packaging/dev.moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 src-tauri/icons/128x128.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
"$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"
|
||||
}
|
||||
|
||||
@@ -1,134 +1,164 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/rounded-logo.png" width="96" />
|
||||
<h1>Moku</h1>
|
||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Series-Detail.png" width="100%" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/screenshots/Search-Bar.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Download-Manager.png" width="100%" /></td>
|
||||
<td><img src=".github/screenshots/Settings-1.png" width="100%" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||

|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots">View all screenshots →</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Reader
|
||||
- **Single**, **double-page**, and **longstrip** reading modes
|
||||
- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
|
||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||
- RTL / LTR reading direction toggle
|
||||
- Configurable page gaps
|
||||
- Full keyboard navigation with rebindable keybinds
|
||||
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
|
||||
- Chapter-relative page counter that updates live as you scroll through the infinite strip
|
||||
- Auto-mark chapters as read when the last page is reached
|
||||
|
||||
### Library
|
||||
- Grid view of your entire manga collection with lazy-loaded cover art
|
||||
- Filter tabs: **Saved**, **Downloaded**, and **All**
|
||||
- Genre tag filter chips — multi-select to narrow by any combination of tags
|
||||
- In-line search
|
||||
- Context menu: open, add/remove from library
|
||||
|
||||
### Series Detail
|
||||
- Cover, author, artist, status badge, genres, and synopsis
|
||||
- Read progress bar with percentage
|
||||
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
|
||||
- Chapter list with scanlator, upload date, and in-progress page indicator
|
||||
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
|
||||
- Sort by newest or oldest first
|
||||
- Jump-to-chapter input
|
||||
- Bulk download menu: from current chapter, unread only, or all
|
||||
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
|
||||
- Collapsible source details panel with source ID, language, and source migration
|
||||
|
||||
### Search
|
||||
- Cross-source search running up to 3 concurrent requests
|
||||
- Language filter bar (preferred language default, per-language, or all)
|
||||
- Results grouped by source with skeleton loading states
|
||||
|
||||
### Sources & Extensions
|
||||
- Browse and search installed sources, grouped by extension with per-language expansion
|
||||
- Extension manager: install, update, remove, and install from external APK URL
|
||||
- Repo refresh with update count badge
|
||||
|
||||
### Downloads
|
||||
- Download queue with live progress
|
||||
|
||||
### History
|
||||
- Reading history grouped by day with relative timestamps
|
||||
- Per-entry thumbnail, chapter name, and last-read page
|
||||
- Full-text search across titles and chapter names
|
||||
- One-click clear
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
|
||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **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 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
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Nix (recommended)**
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### Windows
|
||||
|
||||
**winget:**
|
||||
|
||||
```powershell
|
||||
winget install Moku.Moku
|
||||
```
|
||||
|
||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||
|
||||
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### Linux (Flatpak, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/moku
|
||||
flatpak install io.github.moku_app.Moku
|
||||
```
|
||||
|
||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
```
|
||||
|
||||
### Nix
|
||||
|
||||
```bash
|
||||
nix run github:moku-project/Moku
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
**From source**
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/moku
|
||||
cd moku
|
||||
nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||
|
||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,104 +1,4 @@
|
||||
Todo:
|
||||
3. Explore Manga Upscaler & Other Image Processing
|
||||
4. Font Weird on Flatpak, Investigate and Fix
|
||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
||||
Revival of the TODO List!!!!!
|
||||
|
||||
|
||||
Bugs:
|
||||
|
||||
- Add Back after Search & Clear on Search
|
||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
||||
|
||||
|
||||
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
||||
skip chapters, etc
|
||||
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
||||
- Reset to top when user clicks next chapter in reader.
|
||||
|
||||
|
||||
- Fix Downloaded in Library (Tags Broken) & All
|
||||
- Using Delete All Crashes App (But Works)
|
||||
- Fix Folder Display in Library
|
||||
- Add Version Tags (To Find Version)
|
||||
- Sidebar Icon Highlighted
|
||||
- Introduce Deduplication into Library & Search
|
||||
|
||||
|
||||
Features:
|
||||
- Add PDF Textbook Support
|
||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
||||
- Migration Features
|
||||
- Multi-Page Long Screenshot
|
||||
- Add Consumet Api (Anime & Light Novel Support)
|
||||
|
||||
|
||||
Big Revisions:
|
||||
0. Expand into fully-fledged reader, with modular manga support
|
||||
1. Anime & Novel Support
|
||||
2. Tracker Support
|
||||
3. Cloudflare Bypass Enable Support
|
||||
4. macOS Support (feasible)
|
||||
|
||||
|
||||
|
||||
Testing:
|
||||
|
||||
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
|
||||
- Fix the Mark as Read (Glitched)
|
||||
|
||||
|
||||
Completed:
|
||||
8. Fix Polling on Download Manager (Instantanous Response)
|
||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
||||
7. Fix Scaling (100 = 125% and so forth)
|
||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
||||
11. Reader & UI needs download and other Notifications
|
||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
||||
- Add Refresh Details on Series Details.
|
||||
- Patch GenreDrill & Integrate into Explore Folder
|
||||
18. Disable NSFW Extensions option in settings
|
||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
||||
- Remove Series Detail Mark Read & Unread
|
||||
20. Expand History (Total Time Read, etc)
|
||||
12. Delete all Downloads should also cancel all download queues
|
||||
13. Cancel Download along with Queue & Download Timeout Feature
|
||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
||||
- Extensions Page no Longer Loading efficiently
|
||||
- Map out MangaPreview tags to GenreDrill
|
||||
- GenreDrill & GenreFilter pages do not populate completely.
|
||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
||||
- Clean up Migrate Model to be more initutive
|
||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
||||
- Properly Kill Tachidesk-Server
|
||||
- Fix scaling on splash screen
|
||||
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
|
||||
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
|
||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Important Commands:
|
||||
cd ~/Projects/Manga/Moku
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||
|
||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||
- Reminder to Completely Test Settings
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-scripts/pkgbuild-bump.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Run this AFTER the git tag has been pushed to GitHub.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-scripts/pkgbuild-bump.sh 0.3.0
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||
|
||||
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||
|
||||
command -v curl &>/dev/null || die "curl not found"
|
||||
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||
|
||||
section "Patching PKGBUILD → ${VERSION}"
|
||||
|
||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v${VERSION}.tar.gz"
|
||||
info "Fetching source tarball to compute sha256…"
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||
|
||||
# Replace only the first sha256 entry (source tarball) inside sha256sums=('...')
|
||||
# The suwayomi jar and jdk hashes are pinned and stay untouched.
|
||||
# Strategy: match the opening sha256sums=('' then swap just that first hash.
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1${TARBALL_SHA}/" "$PKGBUILD"
|
||||
|
||||
# Verify the replacement landed
|
||||
if ! grep -q "$TARBALL_SHA" "$PKGBUILD"; then
|
||||
die "sha256 replacement failed — check PKGBUILD sha256sums format"
|
||||
fi
|
||||
|
||||
success "PKGBUILD patched (pkgver=${VERSION}, sha256=${TARBALL_SHA})"
|
||||
info "PKGBUILD → ${PKGBUILD}"
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-scripts/release.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Usage:
|
||||
# ./build-scripts/release.sh 0.2.0
|
||||
#
|
||||
# Requires: nix, flatpak-builder, appstream
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
||||
|
||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
||||
[[ $# -lt 1 ]] && die "Usage: $0 <version>"
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
||||
PKGBUILD="${REPO_ROOT}/PKGBUILD"
|
||||
|
||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
||||
section "Pre-flight"
|
||||
command -v nix &>/dev/null || die "nix not found"
|
||||
command -v curl &>/dev/null || die "curl not found"
|
||||
[[ -f "$FLATPAK_MANIFEST" ]] || die "Flatpak manifest not found: $FLATPAK_MANIFEST"
|
||||
[[ -f "$PKGBUILD" ]] || die "PKGBUILD not found: $PKGBUILD"
|
||||
success "OK"
|
||||
|
||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
||||
section "Bumping version → ${VERSION}"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
||||
success "tauri.conf.json → ${VERSION}"
|
||||
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
||||
success "Cargo.toml → ${VERSION}"
|
||||
|
||||
# flake.nix has two `version = "x.y.z";` strings inside the frontend
|
||||
# derivation and fetchPnpmDeps — both need to match.
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"${VERSION}\";/g" \
|
||||
"${REPO_ROOT}/flake.nix"
|
||||
success "flake.nix → ${VERSION}"
|
||||
|
||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
||||
section "Building frontend"
|
||||
cd "$REPO_ROOT"
|
||||
nix develop --command pnpm install --frozen-lockfile
|
||||
nix develop --command pnpm build
|
||||
success "Frontend built → dist/"
|
||||
|
||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
||||
section "Regenerating cargo-sources.json"
|
||||
cd "$REPO_ROOT"
|
||||
nix-shell \
|
||||
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
||||
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||
success "cargo-sources.json updated"
|
||||
|
||||
section "Rebuilding frontend-dist.tar.gz"
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
||||
|
||||
section "Patching frontend-dist sha256 in dev.moku.app.yml"
|
||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
||||
cat > "$PATCH_SCRIPT" << PYEOF
|
||||
import re, sys
|
||||
path = "${FLATPAK_MANIFEST}"
|
||||
new_sha = "${FRONTEND_SHA}"
|
||||
text = open(path).read()
|
||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
||||
replacement = r'\g<1>' + new_sha
|
||||
updated, n = re.subn(pattern, replacement, text)
|
||||
if n == 0:
|
||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
||||
rm -f "$PATCH_SCRIPT"
|
||||
success "dev.moku.app.yml sha256 updated"
|
||||
|
||||
section "Building Flatpak bundle"
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
||||
flatpak-builder \
|
||||
--repo="${REPO_ROOT}/repo" \
|
||||
--force-clean \
|
||||
"${REPO_ROOT}/build-dir" \
|
||||
"$FLATPAK_MANIFEST"
|
||||
|
||||
flatpak build-bundle \
|
||||
"${REPO_ROOT}/repo" \
|
||||
"${REPO_ROOT}/moku.flatpak" \
|
||||
dev.moku.app
|
||||
|
||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
||||
success "moku.flatpak created"
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "v${VERSION} ready"
|
||||
info "Flatpak bundle → ${REPO_ROOT}/moku.flatpak"
|
||||
echo ""
|
||||
warn "PKGBUILD not patched yet — tag must exist on GitHub first."
|
||||
info "After pushing the tag, run:"
|
||||
echo -e " ${CYAN}./build-scripts/pkgbuild-bump.sh ${VERSION}${RESET}"
|
||||
@@ -0,0 +1,99 @@
|
||||
#Requires -Version 7
|
||||
param(
|
||||
[switch]$SkipFrontend,
|
||||
[switch]$SkipSuwayomi
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||
function Need($cmd) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Required tool not found: $cmd"
|
||||
}
|
||||
}
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
Step "Reading nix/versions.nix"
|
||||
$nix = Get-Content "$Root\nix\versions.nix" -Raw
|
||||
$MOKU_VERSION = if ($nix -match 'moku\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "moku version not found" }
|
||||
$SUWA_VERSION = if ($nix -match 'version\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "suwayomi version not found" }
|
||||
$SUWA_HASH = if ($nix -match 'windowsHash\s*=\s*"([^"]+)"') { $Matches[1] } else { Write-Error "windowsHash not found" }
|
||||
Write-Host " moku=$MOKU_VERSION suwayomi=$SUWA_VERSION"
|
||||
|
||||
Need "pnpm"; Need "cargo"; Need "node"
|
||||
|
||||
if (-not $SkipFrontend) {
|
||||
Step "pnpm install"
|
||||
pnpm install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "pnpm install failed" }
|
||||
|
||||
Step "Frontend build"
|
||||
$env:MOKU_TARGET = "static"
|
||||
pnpm build:static
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Frontend build failed" }
|
||||
}
|
||||
|
||||
$BundleDir = "$Root\src-tauri\binaries\suwayomi-bundle"
|
||||
$ZipPath = "$env:TEMP\suwayomi-windows-$SUWA_VERSION.zip"
|
||||
$ExtractDir = "$env:TEMP\suwayomi-extracted-$SUWA_VERSION"
|
||||
|
||||
if (-not $SkipSuwayomi) {
|
||||
Step "Downloading Suwayomi v$SUWA_VERSION"
|
||||
$ZipUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip"
|
||||
|
||||
if (-not (Test-Path $ZipPath)) {
|
||||
Invoke-WebRequest -Uri $ZipUrl -OutFile $ZipPath -UseBasicParsing
|
||||
}
|
||||
|
||||
$actual = (Get-FileHash $ZipPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $SUWA_HASH.ToLower()) {
|
||||
Write-Error "Hash mismatch`n expected: $SUWA_HASH`n got: $actual"
|
||||
}
|
||||
|
||||
Step "Staging bundle"
|
||||
if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force }
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir
|
||||
|
||||
$topDirs = @(Get-ChildItem $ExtractDir -Directory)
|
||||
$topFiles = @(Get-ChildItem $ExtractDir -File)
|
||||
$SrcDir = if ($topDirs.Count -eq 1 -and $topFiles.Count -eq 0) { $topDirs[0].FullName } else { $ExtractDir }
|
||||
|
||||
if (Test-Path $BundleDir) { Remove-Item $BundleDir -Recurse -Force }
|
||||
Copy-Item $SrcDir $BundleDir -Recurse
|
||||
|
||||
$java = Get-ChildItem $BundleDir -Recurse -Filter "java.exe" | Where-Object { $_.FullName -match "jre.bin" } | Select-Object -First 1
|
||||
$jar = Get-ChildItem $BundleDir -Recurse -Filter "Suwayomi-Server.jar" | Select-Object -First 1
|
||||
if (-not $java) { Write-Error "java.exe not found in staged bundle" }
|
||||
if (-not $jar) { Write-Error "Suwayomi-Server.jar not found in staged bundle" }
|
||||
Write-Host " java: $($java.FullName)"
|
||||
Write-Host " jar: $($jar.FullName)"
|
||||
} elseif (-not (Test-Path $BundleDir)) {
|
||||
Write-Error "Bundle dir missing at $BundleDir — run without -SkipSuwayomi first"
|
||||
}
|
||||
|
||||
Step "Patching tauri.conf.json"
|
||||
$tauriConf = "$Root\src-tauri\tauri.conf.json"
|
||||
$original = Get-Content $tauriConf -Raw
|
||||
Set-Content $tauriConf ($original -replace '"beforeBuildCommand":\s*"pnpm build"', '"beforeBuildCommand": ""') -NoNewline
|
||||
|
||||
Step "Tauri build"
|
||||
$env:TAURI_SKIP_DEVSERVER_CHECK = "true"
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
$buildExit = $LASTEXITCODE
|
||||
|
||||
Set-Content $tauriConf $original -NoNewline
|
||||
|
||||
if ($buildExit -ne 0) { Write-Error "Tauri build failed (exit $buildExit)" }
|
||||
|
||||
Step "Artifacts"
|
||||
$out = "$Root\src-tauri\target\x86_64-pc-windows-msvc\release\bundle"
|
||||
$msi = Get-ChildItem "$out\msi" -Filter "*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$exe = Get-ChildItem "$out\nsis" -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
Write-Host "`nDone — Moku $MOKU_VERSION" -ForegroundColor Green
|
||||
if ($msi) { Write-Host " MSI: $($msi.FullName)" -ForegroundColor Yellow }
|
||||
if ($exe) { Write-Host " EXE: $($exe.FullName)" -ForegroundColor Yellow }
|
||||
if (-not $msi -and -not $exe) { Write-Host " No artifacts found in $out" -ForegroundColor Red }
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||
<defs>
|
||||
|
||||
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||
<stop offset="0%" stop-color="#52b888"/>
|
||||
<stop offset="100%" stop-color="#1e5840"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="roundedBounds">
|
||||
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||
</clipPath>
|
||||
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#roundedBounds)">
|
||||
|
||||
<rect width="1280" height="320" fill="#070e09"/>
|
||||
|
||||
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||
fill="url(#leafHero)" opacity="0.97">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
|
||||
<!-- Stack text pinned to bottom -->
|
||||
<text
|
||||
x="640" y="300"
|
||||
text-anchor="middle"
|
||||
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||
font-size="14"
|
||||
letter-spacing="5"
|
||||
fill="#a8c4a8"
|
||||
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -1,46 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771438068,
|
||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -49,53 +18,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-appimage": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1757920913,
|
||||
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ralismark",
|
||||
"repo": "nix-appimage",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1771369470,
|
||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
||||
"lastModified": 1780243769,
|
||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
||||
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -107,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -122,9 +51,7 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-appimage": "nix-appimage",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
@@ -136,11 +63,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771556776,
|
||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
||||
"lastModified": 1780543271,
|
||||
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
||||
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -148,21 +75,6 @@
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
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";
|
||||
};
|
||||
nix-appimage = {
|
||||
url = "github:ralismark/nix-appimage";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
||||
inputs@{ flake-parts, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
@@ -24,22 +19,23 @@
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{ system, pkgs, lib, ... }:
|
||||
{ system, lib, ... }:
|
||||
let
|
||||
pkgs' = import inputs.nixpkgs {
|
||||
versions = import ./nix/versions.nix;
|
||||
version = versions.moku;
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
||||
|
||||
runtimeLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
gtk3
|
||||
@@ -55,151 +51,54 @@
|
||||
gsettings-desktop-schemas
|
||||
];
|
||||
|
||||
frontendSrc = lib.cleanSourceWith {
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter = path: type:
|
||||
let base = builtins.baseNameOf path;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
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 == "tsconfig.node.json"
|
||||
|| base == "vite.config.ts"
|
||||
|| base == "postcss.config.js"
|
||||
|| base == "postcss.config.cjs"
|
||||
|| base == "tailwind.config.js"
|
||||
|| base == "tailwind.config.ts";
|
||||
|| base == "svelte.config.js"
|
||||
|| base == "Cargo.toml"
|
||||
|| base == "Cargo.lock"
|
||||
|| base == "build.rs"
|
||||
|| base == "tauri.conf.json";
|
||||
};
|
||||
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
version = "0.3.0";
|
||||
src = frontendSrc;
|
||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
version = "0.3.0";
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||
moku = pkgs.callPackage ./nix/moku.nix {
|
||||
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
|
||||
appIcon = ./src/lib/assets/moku-icon.svg;
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
installPhase = "cp -r dist $out";
|
||||
};
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
commonArgs = {
|
||||
src = cargoSrc;
|
||||
cargoToml = ./src-tauri/Cargo.toml;
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
moku = craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
meta.mainProgram = "moku";
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
pkgs.gtk3
|
||||
]}" \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_FORCE_SANDBOX 0
|
||||
|
||||
# ── Icon ─────────────────────────────────────────────────────────
|
||||
# Tauri bakes several sizes into src-tauri/icons/. We prefer the
|
||||
# largest PNG (512x512) for the hicolor theme, and also install the
|
||||
# rounded 32x32 used as the in-app logo so small sizes look right.
|
||||
# Adjust the source filenames if yours differ.
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
if [ -f "$src" ]; then
|
||||
install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
fi
|
||||
done
|
||||
|
||||
# @2x variants that Tauri also generates
|
||||
for size in 128x128 256x256; do
|
||||
src="icons/''${size}@2x.png"
|
||||
if [ -f "$src" ]; then
|
||||
install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
fi
|
||||
done
|
||||
|
||||
# Scalable SVG — src/assets/moku-icon.svg is the rounded version
|
||||
# referenced in SplashScreen.tsx. Pull it straight from the source
|
||||
# tree so the launcher always uses the same rounded artwork.
|
||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
# ── .desktop entry ───────────────────────────────────────────────
|
||||
install -Dm644 /dev/stdin \
|
||||
"$out/share/applications/moku.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Moku
|
||||
Comment=Manga reader frontend for Suwayomi
|
||||
Exec=$out/bin/moku
|
||||
Icon=moku
|
||||
Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF
|
||||
'';
|
||||
});
|
||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
|
||||
|
||||
in
|
||||
{
|
||||
# Expose as both a runnable app and installable packages.
|
||||
apps = {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${moku}/bin/moku";
|
||||
};
|
||||
moku = {
|
||||
type = "app";
|
||||
program = "${moku}/bin/moku";
|
||||
};
|
||||
packages = {
|
||||
inherit moku suwayomiServer;
|
||||
default = moku;
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = moku;
|
||||
appimage = nix-appimage.bundlers."${system}".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"; };
|
||||
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
|
||||
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -210,30 +109,27 @@
|
||||
wrapGAppsHook3
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
suwayomiServer
|
||||
cloudflared
|
||||
xdg-utils
|
||||
(python3.withPackages (ps: [
|
||||
ps.aiohttp
|
||||
ps.tomlkit
|
||||
]))
|
||||
];
|
||||
shellHook = ''
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
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}"
|
||||
|
||||
if [ ! -e /usr/bin/xdg-open ]; then
|
||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
||||
fi
|
||||
|
||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
||||
chmod +x "$LINUXDEPLOY"
|
||||
echo "linuxdeploy wrapped with appimage-run"
|
||||
fi
|
||||
|
||||
echo "Moku dev shell"
|
||||
echo " pnpm install && pnpm tauri:dev"
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
|
||||
echo " git commit && git tag && git push"
|
||||
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"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Moku</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: dev.moku.app
|
||||
app-id: io.github.moku_project.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -9,16 +9,22 @@ separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --socket=session-bus
|
||||
- --socket=system-bus
|
||||
- --filesystem=home
|
||||
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=com.canonical.AppMenu.Registrar
|
||||
- --talk-name=com.canonical.indicator.application
|
||||
|
||||
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||
- --filesystem=xdg-data/moku:create
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-download
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
@@ -26,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:
|
||||
@@ -33,13 +110,10 @@ modules:
|
||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||
dest-filename: jdk.tar.gz
|
||||
|
||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
||||
- name: catch-abort
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
@@ -49,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>
|
||||
@@ -92,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
|
||||
@@ -111,24 +182,20 @@ modules:
|
||||
cat > /app/bin/tachidesk-server << 'EOF'
|
||||
#!/bin/sh
|
||||
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 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.
|
||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
||||
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"
|
||||
|
||||
@@ -138,8 +205,6 @@ modules:
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
||||
# that thread instead of crashing the whole JVM process.
|
||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||
|
||||
exec /app/jre/bin/java \
|
||||
@@ -155,8 +220,8 @@ modules:
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
|
||||
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
|
||||
dest-filename: Suwayomi-Server.jar
|
||||
|
||||
- name: moku
|
||||
@@ -166,22 +231,24 @@ 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/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: git
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.10.0
|
||||
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: c9bb5ee6613b2bc61e69a92cc1ef0029da3b61138d51b01d363f8ea524e51996
|
||||
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
rustToolchain,
|
||||
runtimeLibs,
|
||||
suwayomiServer,
|
||||
version,
|
||||
versions,
|
||||
src,
|
||||
appIcon,
|
||||
}:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "moku";
|
||||
inherit version src;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
rustToolchain
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
pkg-config
|
||||
wrapGAppsHook3
|
||||
rustPlatform.cargoSetupHook
|
||||
];
|
||||
|
||||
buildInputs = runtimeLibs;
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku";
|
||||
inherit version src;
|
||||
fetcherVersion = 3;
|
||||
hash = versions.frontend.pnpmHash;
|
||||
};
|
||||
|
||||
cargoDeps = pkgs.rustPlatform.importCargoLock {
|
||||
lockFile = ../src-tauri/Cargo.lock;
|
||||
outputHashes = {
|
||||
"tauri-plugin-discord-rpc-0.1.0" = versions.gitDeps.tauri-plugin-discord-rpc;
|
||||
};
|
||||
};
|
||||
|
||||
env = {
|
||||
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||
TAURI_SKIP_DEVSERVER_CHECK = "true";
|
||||
cargoRoot = "src-tauri";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
export HOME=$(mktemp -d)
|
||||
pnpm tauri:build
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
install -Dm755 src-tauri/target/release/moku $out/bin/moku
|
||||
|
||||
mkdir -p "$out/share/applications"
|
||||
cat > "$out/share/applications/moku.desktop" << EOF2
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Moku
|
||||
Comment=Manga reader frontend for Suwayomi
|
||||
Exec=$out/bin/moku
|
||||
Icon=moku
|
||||
Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF2
|
||||
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
f="src-tauri/icons/$size.png"
|
||||
[ -f "$f" ] && install -Dm644 "$f" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
done
|
||||
|
||||
for size in 128x128 256x256; do
|
||||
f="src-tauri/icons/''${size}@2x.png"
|
||||
[ -f "$f" ] && install -Dm644 "$f" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
done
|
||||
|
||||
install -Dm644 "${appIcon}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
pkgs.gtk3
|
||||
]}" \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ suwayomiServer ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
|
||||
meta.mainProgram = "moku";
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{ pkgs, rustToolchain, version, versions }:
|
||||
|
||||
{
|
||||
bump = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [
|
||||
gnused
|
||||
coreutils
|
||||
git
|
||||
xxd
|
||||
rustToolchain
|
||||
nodejs_22
|
||||
pnpm
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||
];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
|
||||
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
|
||||
cd "$REPO"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:static
|
||||
|
||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||
|
||||
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
|
||||
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
|
||||
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
updated, n = re.subn(
|
||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||
r'\g<1>' + sha, text)
|
||||
if n == 0:
|
||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
|
||||
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#update -- $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
update = pkgs.writeShellApplication {
|
||||
name = "moku-update";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
|
||||
text = ''
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
VERSIONS="$REPO/nix/versions.nix"
|
||||
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
|
||||
if [[ $# -ge 1 ]]; then
|
||||
VERSION="$1"
|
||||
else
|
||||
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
fi
|
||||
|
||||
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
|
||||
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
|
||||
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||
|
||||
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
|
||||
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||
|
||||
if [[ $# -ge 2 ]]; then
|
||||
SUWA_VER="$2"
|
||||
BASE="https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v''${SUWA_VER}"
|
||||
|
||||
echo "Fetching Suwayomi v''${SUWA_VER} hashes (5 downloads)..."
|
||||
|
||||
sha_of() { curl -fsSL "$1" | sha256sum | awk '{print $1}'; }
|
||||
to_sri() { echo "$1" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/'; }
|
||||
|
||||
JAR_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}.jar")
|
||||
WIN_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-windows-x64.zip")
|
||||
LINUX_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-linux-x64.tar.gz")
|
||||
ARM64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-arm64.tar.gz")
|
||||
X64_SHA=$(sha_of "''${BASE}/Suwayomi-Server-v''${SUWA_VER}-macOS-x64.tar.gz")
|
||||
|
||||
JAR_SRI=$(to_sri "$JAR_SHA")
|
||||
|
||||
sed -i "s/version = \"[^\"]*\"/version = \"''${SUWA_VER}\"/" "$VERSIONS"
|
||||
sed -i "s|hash = \"sha256-[^\"]*\"|hash = \"''${JAR_SRI}\"|" "$VERSIONS"
|
||||
sed -i "s|windowsHash = \"[^\"]*\"|windowsHash = \"''${WIN_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|linuxHash = \"[^\"]*\"|linuxHash = \"''${LINUX_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|macosArm64Hash = \"[^\"]*\"|macosArm64Hash = \"''${ARM64_SHA}\"|" "$VERSIONS"
|
||||
sed -i "s|macosX64Hash = \"[^\"]*\"|macosX64Hash = \"''${X64_SHA}\"|" "$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" "$JAR_SHA" <<'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
|
||||
|
||||
echo "Suwayomi hashes written."
|
||||
fi
|
||||
|
||||
echo "Done — versions.nix, flatpak manifest, and PKGBUILD patched for v$VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
flatpak = pkgs.writeShellApplication {
|
||||
name = "moku-flatpak";
|
||||
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||
text = ''
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$REPO/io.github.moku_project.Moku.yml"
|
||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.moku_project.Moku
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
echo "moku.flatpak created"
|
||||
'';
|
||||
};
|
||||
|
||||
tunnel = pkgs.writeShellApplication {
|
||||
name = "moku-tunnel";
|
||||
runtimeInputs = with pkgs; [ cloudflared ];
|
||||
text = ''
|
||||
PORT="''${1:-4567}"
|
||||
cloudflared tunnel --url "http://localhost:$PORT"
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
fetchurl,
|
||||
makeWrapper,
|
||||
jdk21_headless,
|
||||
versions,
|
||||
}:
|
||||
let
|
||||
jdk = jdk21_headless;
|
||||
ver = versions.suwayomi;
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "suwayomi-server";
|
||||
version = ver.version;
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${ver.version}/Suwayomi-Server-v${ver.version}.jar";
|
||||
hash = ver.hash;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
dontUnpack = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
install -Dm644 $src $out/share/suwayomi-server/suwayomi-server.jar
|
||||
|
||||
makeWrapper ${jdk}/bin/java $out/bin/suwayomi-server \
|
||||
--add-flags "-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false" \
|
||||
--add-flags "-jar $out/share/suwayomi-server/suwayomi-server.jar"
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Free and open source manga reader server that runs extensions built for Mihon (Tachiyomi)";
|
||||
homepage = "https://github.com/Suwayomi/Suwayomi-Server";
|
||||
downloadPage = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases";
|
||||
changelog = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/tag/v${ver.version}";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = jdk.meta.platforms;
|
||||
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||
mainProgram = "suwayomi-server";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
moku = "0.10.0";
|
||||
|
||||
suwayomi = {
|
||||
version = "2.2.2196";
|
||||
hash = "sha256-jnJEwmlFZmGodwX3RvDYcnV3Cql2urfGkg5NUT6Xw/Y=";
|
||||
windowsHash = "457ca4a64a57e0d274a87203d25e962103bcb456ee30ada3ea47328a3093329d";
|
||||
linuxHash = "e13d63ceb7e2b15e83d0a78281e8c1c04ac4a833caa73e5a2b68fbaf0cb20c1f";
|
||||
macosArm64Hash = "9e3dbebc7475707e8d11c56a473385c00b09bde0103d013bc1cb3d06c89e5c43";
|
||||
macosX64Hash = "eadee02060b780a5febfb8dada2f89c7bd7db5905cfd20d47eaca02fcde8c9c5";
|
||||
};
|
||||
|
||||
frontend = {
|
||||
pnpmHash = "sha256-18twdFhprV9v9hzvqxuVDHD6Tm4zHNDJs7s6l/7ClBo=";
|
||||
distHash = "7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5";
|
||||
distHashSri = "sha256-Z27CJz/9mmkkiEnF1R3E1ZpdW2j7unpP5+e1cqXyXxQ=";
|
||||
};
|
||||
|
||||
gitDeps = {
|
||||
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||
};
|
||||
|
||||
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||
tarballHash = "";
|
||||
}
|
||||
@@ -1,41 +1,49 @@
|
||||
{
|
||||
"name": "moku",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"dev": "vite dev",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.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",
|
||||
"@types/node": "^25.9.3",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/browser": "^8.0.3",
|
||||
"@capacitor/core": "^8.4.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"capacitor-native-biometric": "^4.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>dev.moku.app</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2025-01-01">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=dev.moku.app
|
||||
Icon=io.github.moku_project.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.moku_project.Moku</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
<p>
|
||||
Features include library management, chapter tracking, extension support,
|
||||
reading history, notifications, and Discord Rich Presence integration.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||
<caption>Home screen showing your manga library</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||
<caption>Built-in manga reader</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||
<caption>Discover new manga across hundreds of sources</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||
<caption>Download manager</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||
<caption>Settings</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.9.0" date="2025-04-01">
|
||||
<description>
|
||||
<p>Latest release with improved stability and UI refinements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.8.0" date="2025-04-01">
|
||||
<description>
|
||||
<p>Old release with improved stability and UI refinements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.0" date="2025-03-22">
|
||||
<description>
|
||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.3.0"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "moku_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "moku"
|
||||
@@ -15,13 +15,28 @@ 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"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
sysinfo = "0.32"
|
||||
dirs = "5"
|
||||
urlencoding = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Security_Credentials_UI",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# — Suwayomi launcher for Linux AppImage/deb.
|
||||
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||
set -e
|
||||
|
||||
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||
# We resolve relative to this script's own location.
|
||||
SELF="$0"
|
||||
while [ -L "$SELF" ]; do
|
||||
SELF="$(readlink "$SELF")"
|
||||
done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||
|
||||
# Tauri places resources one level up from the binary on Linux.
|
||||
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||
find_resource() {
|
||||
for candidate in \
|
||||
"${SCRIPT_DIR}" \
|
||||
"${SCRIPT_DIR}/../resources" \
|
||||
"${SCRIPT_DIR}/resources"
|
||||
do
|
||||
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||
echo "$(cd "$candidate" && pwd)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
RESOURCE_DIR=$(find_resource) || {
|
||||
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||
|
||||
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||
echo "[launcher] JAVA=$JAVA" >&2
|
||||
echo "[launcher] JAR=$JAR" >&2
|
||||
|
||||
if [ ! -x "$JAVA" ]; then
|
||||
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "PREVIEW"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||
sed -i \
|
||||
-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 = 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"
|
||||
|
||||
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||
# bring down the whole server process (mirrors the Flatpak build).
|
||||
if [ -f "$CATCH_ABORT" ]; then
|
||||
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
fi
|
||||
|
||||
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 "$JAR"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
# Moku — Suwayomi launcher sidecar for macOS.
|
||||
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||
set -e
|
||||
|
||||
# Resolve the real directory of this script, following symlinks.
|
||||
SELF="$0"
|
||||
while [ -L "$SELF" ]; do
|
||||
SELF="$(readlink "$SELF")"
|
||||
done
|
||||
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||
|
||||
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||
# bundle = Contents/Resources/suwayomi-bundle/
|
||||
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||
find_bundle() {
|
||||
local base="$1"
|
||||
for candidate in \
|
||||
"${base}/../Resources/suwayomi-bundle" \
|
||||
"${base}/suwayomi-bundle" \
|
||||
"${base}/../suwayomi-bundle"
|
||||
do
|
||||
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||
echo "$(cd "$candidate" && pwd)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
BUNDLE=$(find_bundle "$DIR") || {
|
||||
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||
echo "[sidecar] Tried:" >&2
|
||||
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||
echo " $DIR/suwayomi-bundle" >&2
|
||||
echo " $DIR/../suwayomi-bundle" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
JAVA="${BUNDLE}/jre/bin/java"
|
||||
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||
|
||||
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||
echo "[sidecar] JAVA=$JAVA" >&2
|
||||
echo "[sidecar] JAR=$JAR" >&2
|
||||
|
||||
if [ ! -x "$JAVA" ]; then
|
||||
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||
# We call java directly so all JVM flags reach it properly.
|
||||
exec "$JAVA" \
|
||||
-Djava.awt.headless=true \
|
||||
"$@" \
|
||||
-jar "$JAR"
|
||||
@@ -1,20 +1,50 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Allow launching suwayomi-server sidecar",
|
||||
"windows": ["main"],
|
||||
"description": "Default permissions for Moku",
|
||||
"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",
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "binaries/suwayomi-server",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-inner-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-scale-factor",
|
||||
"process:default",
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"store:default",
|
||||
"discord-rpc:default",
|
||||
"discord-rpc:allow-connect",
|
||||
"discord-rpc:allow-disconnect",
|
||||
"discord-rpc:allow-set-activity",
|
||||
"discord-rpc:allow-clear-activity",
|
||||
"discord-rpc:allow-is-running",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "http-scope",
|
||||
"description": "HTTP fetch scope",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://*:*/*" },
|
||||
{ "url": "https://*:*/*" },
|
||||
{ "url": "http://*/*" },
|
||||
{ "url": "https://*/*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,100 @@
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Save Moku app data backup")
|
||||
.set_file_name(&filename)
|
||||
.add_filter("Moku Backup", &["zip"])
|
||||
.blocking_save_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Open Moku app data backup")
|
||||
.add_filter("Moku Backup", &["zip"])
|
||||
.blocking_pick_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||
let dir = backup_dir(&app);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("auto-moku-backup-")
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||
let _ = std::fs::remove_file(old.path());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||
let dir = backup_dir(&app);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||
let base = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let content = std::fs::read_to_string(base.join(&name))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
(name, content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_hello {
|
||||
use windows::{
|
||||
core::HSTRING,
|
||||
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||
},
|
||||
};
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
std::ffi::OsStr::new(s)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn try_focus_hello_dialog() -> bool {
|
||||
let cls = to_wide("Credential Dialog Xaml Host");
|
||||
unsafe {
|
||||
let Ok(hwnd) = FindWindowW(
|
||||
windows::core::PCWSTR(cls.as_ptr()),
|
||||
windows::core::PCWSTR::null(),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if IsIconic(hwnd).as_bool() {
|
||||
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||
}
|
||||
let _ = BringWindowToTop(hwnd);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_focus(retries: u32, delay_ms: u64) {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
for _ in 0..retries {
|
||||
if try_focus_hello_dialog() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||
let reason = reason.to_owned();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
nudge_focus(5, 250);
|
||||
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||
.and_then(|op| {
|
||||
nudge_focus(5, 250);
|
||||
op.get()
|
||||
});
|
||||
let _ = tx.send(outcome);
|
||||
});
|
||||
|
||||
let result = rx
|
||||
.recv()
|
||||
.map_err(|e| format!("internalError:{e:?}"))?
|
||||
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||
|
||||
match result {
|
||||
UserConsentVerificationResult::Verified => Ok(()),
|
||||
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
|
||||
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
|
||||
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
|
||||
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
|
||||
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
|
||||
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
|
||||
_ => Err("authenticationFailed".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_available() -> bool {
|
||||
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||
UserConsentVerifier::CheckAvailabilityAsync()
|
||||
.and_then(|op| op.get())
|
||||
.map(|a| a == UserConsentVerifierAvailability::Available)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows_hello::authenticate(&_reason);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Err("notSupported".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn windows_hello_available() -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows_hello::is_available();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod backup;
|
||||
pub mod biometric;
|
||||
pub mod server;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
pub mod updater;
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
|
||||
use crate::ServerState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn spawn_server(
|
||||
binary: String,
|
||||
binary_args: Option<String>,
|
||||
web_ui_enabled: bool,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), SpawnError> {
|
||||
{
|
||||
let state = app.state::<ServerState>();
|
||||
if state.0.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
|
||||
let binary_args = binary_args.unwrap_or_default();
|
||||
|
||||
server::do_log(
|
||||
&mut log,
|
||||
&format!(
|
||||
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
|
||||
binary, binary_args, web_ui_enabled, data_dir
|
||||
),
|
||||
);
|
||||
|
||||
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
|
||||
|
||||
let mut invocation =
|
||||
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||
server::do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||
e
|
||||
})?;
|
||||
|
||||
if !binary_args.trim().is_empty() {
|
||||
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
|
||||
let mut merged = extra;
|
||||
merged.extend(invocation.args);
|
||||
invocation.args = merged;
|
||||
}
|
||||
|
||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
}
|
||||
|
||||
let working_dir = invocation
|
||||
.working_dir
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
server::do_log(
|
||||
&mut log,
|
||||
&format!(
|
||||
"[spawn_server] bin={:?} args={:?} cwd={:?}",
|
||||
invocation.bin, invocation.args, working_dir
|
||||
),
|
||||
);
|
||||
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
let cmd = app
|
||||
.shell()
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
server::do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
server::kill_tachidesk(&app);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use sysinfo::Disks;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
|
||||
// ── Key-value store (used by the frontend via platformService) ────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store(format!("{}.json", key))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let value = store.get(&key);
|
||||
Ok(value.map(|v| v.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(format!("{}.json", key))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&value).map_err(|e| e.to_string())?;
|
||||
store.set(key, parsed);
|
||||
store.save().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("credentials.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
if value.is_empty() {
|
||||
store.delete(&key);
|
||||
} else {
|
||||
store.set(&key, serde_json::Value::String(value));
|
||||
}
|
||||
store.save().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store("credentials.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
|
||||
}
|
||||
|
||||
// ── Disk / downloads storage ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageInfo {
|
||||
pub manga_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
pub free_bytes: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path.trim());
|
||||
}
|
||||
suwayomi_data_dir().join("downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
let path = resolve_downloads_path(&downloads_path);
|
||||
|
||||
let manga_bytes = if path.exists() {
|
||||
WalkDir::new(&path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let disk = disks
|
||||
.iter()
|
||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes: disk.total_space(),
|
||||
free_bytes: disk.available_space(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn migrate_downloads(
|
||||
app: tauri::AppHandle,
|
||||
src: String,
|
||||
dst: String,
|
||||
) -> Result<(), String> {
|
||||
use std::fs;
|
||||
|
||||
let src_path = PathBuf::from(src.trim());
|
||||
let dst_path = PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64;
|
||||
|
||||
let _ = app.emit(
|
||||
"migrate_progress",
|
||||
serde_json::json!({ "done": 0u64, "total": total, "current": "" }),
|
||||
);
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel = entry
|
||||
.path()
|
||||
.strip_prefix(&src_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let target = dst_path.join(rel);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||
done += 1;
|
||||
let _ = app.emit(
|
||||
"migrate_progress",
|
||||
serde_json::json!({
|
||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::server::resolve::strip_unc;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_path(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let p = strip_unc(PathBuf::from(path.trim()));
|
||||
std::process::Command::new("explorer")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose Downloads Folder")
|
||||
.blocking_pick_folder()
|
||||
.map(|p| p.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Choose Server Binary")
|
||||
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Choose Server Binary")
|
||||
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Choose Server Binary")
|
||||
.add_filter("Executable or JAR", &["jar", "sh"]);
|
||||
|
||||
dialog.blocking_pick_file().map(|p| p.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn exit_app(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
fn remove_dir_best_effort(path: &std::path::Path) {
|
||||
if path.is_file() {
|
||||
if let Err(e) = std::fs::remove_file(path) {
|
||||
if e.raw_os_error() == Some(32) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
remove_dir_best_effort(&entry.path());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let locked = if path.is_file() {
|
||||
std::fs::OpenOptions::new().write(true).open(path).is_err()
|
||||
} else if path.is_dir() {
|
||||
std::fs::read_dir(path).is_err()
|
||||
} else {
|
||||
return true;
|
||||
};
|
||||
if !locked {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main").ok_or("no main window")?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||
|
||||
window
|
||||
.with_webview(move |_wv| {
|
||||
let _ = tx.send(Ok(()));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
rx.await.map_err(|e| e.to_string())??;
|
||||
|
||||
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||
if cache_dir.exists() {
|
||||
wait_until_deletable(&cache_dir, 3);
|
||||
remove_dir_best_effort(&cache_dir);
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
let data_dir = suwayomi_data_dir();
|
||||
for dir in &["cache/kcef", "logs"] {
|
||||
let p = data_dir.join(dir);
|
||||
if p.exists() {
|
||||
remove_dir_best_effort(&p);
|
||||
}
|
||||
}
|
||||
for dir in &["downloads/thumbnails"] {
|
||||
let p = data_dir.join(dir);
|
||||
if p.exists() {
|
||||
remove_dir_best_effort(&p);
|
||||
let _ = std::fs::create_dir_all(&p);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use crate::server::resolve::suwayomi_data_dir;
|
||||
|
||||
crate::server::kill_tachidesk(&app);
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
|
||||
|
||||
for entry_name in &targets {
|
||||
let p = data_dir.join(entry_name);
|
||||
if p.exists() {
|
||||
wait_until_deletable(&p, 10);
|
||||
}
|
||||
}
|
||||
|
||||
for entry_name in &targets {
|
||||
let p = data_dir.join(entry_name);
|
||||
if p.is_dir() {
|
||||
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||
} else if p.exists() {
|
||||
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/moku-project/Moku/releases?per_page=30")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
let releases: Vec<GhRelease> =
|
||||
serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(releases
|
||||
.into_iter()
|
||||
.map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
pub async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::io::Write;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Release {
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"https://api.github.com/repos/moku-project/Moku/releases/tags/{}",
|
||||
tag
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"GitHub API returned {} for tag {}",
|
||||
resp.status(),
|
||||
tag
|
||||
));
|
||||
}
|
||||
|
||||
let release: Release = serde_json::from_str(&resp.text().await.map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||
|
||||
let total = if asset.size > 0 {
|
||||
Some(asset.size)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut resp = client
|
||||
.get(&asset.browser_download_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
downloaded += chunk.len() as u64;
|
||||
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||
}
|
||||
drop(file);
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
std::process::Command::new(&tmp_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("update-launching", ());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,353 +1,168 @@
|
||||
use std::path::PathBuf;
|
||||
mod commands;
|
||||
mod server;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
struct ServerState(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageInfo {
|
||||
manga_bytes: u64,
|
||||
total_bytes: u64,
|
||||
free_bytes: u64,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/"))
|
||||
});
|
||||
base.join("Tachidesk/downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
let path = resolve_downloads_path(&downloads_path);
|
||||
|
||||
let manga_bytes = if path.exists() {
|
||||
WalkDir::new(&path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
} else {
|
||||
0
|
||||
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;
|
||||
|
||||
let stat_path = if path.exists() { path.clone() } else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let disk = disks
|
||||
.iter()
|
||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
const IPC_PORT: u16 = 47823;
|
||||
const HANDSHAKE: &[u8] = b"MOKU:1\n";
|
||||
const FOCUS_CMD: &[u8] = b"focus\n";
|
||||
|
||||
let total_bytes = disk.total_space();
|
||||
let free_bytes = disk.available_space();
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
fn do_quit(app: &tauri::AppHandle) {
|
||||
server::kill_tachidesk(app);
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Returns the true OS-level scale factor for the main window.
|
||||
/// On Linux this bypasses WebKitGTK's unreliable devicePixelRatio.
|
||||
/// On macOS the value comes directly from the native window.
|
||||
#[tauri::command]
|
||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
println!("Killed tracked server child.");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/FI", "IMAGENAME eq tachidesk*"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.arg("-f")
|
||||
.arg("tachidesk")
|
||||
.status();
|
||||
}
|
||||
|
||||
/// The default server.conf we seed on first launch.
|
||||
/// Mirrors the Flatpak wrapper: headless, no tray, no browser pop-up.
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "stable"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
/// Ensure the Suwayomi data dir and server.conf exist, and that the three
|
||||
/// keys that cause GUI/JCEF crashes are always set to safe values.
|
||||
/// This mirrors the shell-script logic in the Flatpak's tachidesk-server wrapper.
|
||||
fn seed_server_conf(data_dir: &PathBuf) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
if !conf_path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
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);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||
eprintln!("Could not write server.conf: {e}");
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
|
||||
let mut buf = [0u8; 32];
|
||||
let Ok(n) = stream.read(&mut buf) else { return };
|
||||
let msg = &buf[..n];
|
||||
|
||||
if !msg.starts_with(HANDSHAKE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conf already exists — patch the three critical keys in-place.
|
||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
||||
|
||||
let patched = patch_conf_key(
|
||||
patch_conf_key(
|
||||
patch_conf_key(
|
||||
contents,
|
||||
"server.webUIEnabled",
|
||||
"false",
|
||||
),
|
||||
"server.initialOpenInBrowserEnabled",
|
||||
"false",
|
||||
),
|
||||
"server.systemTrayEnabled",
|
||||
"false",
|
||||
);
|
||||
|
||||
let _ = std::fs::write(&conf_path, patched);
|
||||
}
|
||||
|
||||
/// Replace `key = <value>` in a HOCON/properties-style conf, or append it
|
||||
/// if the key is absent.
|
||||
fn patch_conf_key(mut text: String, key: &str, value: &str) -> String {
|
||||
let replacement = format!("{key} = {value}");
|
||||
// Find a line that starts with the key (tolerant of surrounding whitespace)
|
||||
if let Some(pos) = text.lines().position(|l| l.trim_start().starts_with(key)) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// We need an owned replacement; rebuild from scratch.
|
||||
let owned: Vec<String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
if i == pos { replacement.clone() } else { l.to_string() }
|
||||
})
|
||||
.collect();
|
||||
return owned.join("\n");
|
||||
}
|
||||
// Key absent — append.
|
||||
if !text.ends_with('\n') { text.push('\n'); }
|
||||
text.push_str(&replacement);
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
/// Resolve the Suwayomi data directory.
|
||||
///
|
||||
/// - Linux: $XDG_DATA_HOME/moku/tachidesk (matches Flatpak path)
|
||||
/// - macOS: ~/Library/Application Support/dev.moku.app/tachidesk
|
||||
fn suwayomi_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||
.join("dev.moku.app/tachidesk")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
});
|
||||
base.join("moku/tachidesk")
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to spawn the server process.
|
||||
struct ServerInvocation {
|
||||
/// Path to the executable (javaw.exe on Windows, the sidecar script on macOS/Linux).
|
||||
bin: std::ffi::OsString,
|
||||
/// Extra args prepended before the Suwayomi rootDir flag.
|
||||
/// On Windows: ["-jar", "<path-to-jar>"]
|
||||
/// Elsewhere: []
|
||||
prefix_args: Vec<String>,
|
||||
/// Working directory for the child process.
|
||||
/// On Windows this must be the bundle folder so javaw can find the JRE and jar.
|
||||
/// Elsewhere: None (inherit).
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolve the server binary path.
|
||||
///
|
||||
/// If the frontend passes a non-empty `binary` string (user override in
|
||||
/// Settings) we always use that — on Linux this is the nixpkgs/Flatpak path.
|
||||
///
|
||||
/// Otherwise we look for the Tauri-bundled sidecar inside the resource dir
|
||||
/// and, on Windows, build the javaw + jar invocation from the suwayomi-bundle.
|
||||
fn resolve_server_binary(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
) -> Result<ServerInvocation, String> {
|
||||
if !binary.trim().is_empty() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: std::ffi::OsString::from(binary),
|
||||
prefix_args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("Could not locate resource dir: {e}"))?;
|
||||
|
||||
// ── Windows: invoke the bundled javaw.exe with -jar Suwayomi-Launcher.jar ──
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let sidecar = resource_dir.join("suwayomi-server-x86_64-pc-windows-msvc.exe");
|
||||
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||
let jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||
|
||||
if sidecar.exists() && jar.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: sidecar.into_os_string(),
|
||||
prefix_args: vec![
|
||||
"-jar".to_string(),
|
||||
jar.to_string_lossy().into_owned(),
|
||||
],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── macOS / Linux: sidecar script is self-contained ──
|
||||
let candidates = [
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
// plain name as a dev/Linux fallback
|
||||
"suwayomi-server",
|
||||
];
|
||||
|
||||
for name in &candidates {
|
||||
let p = resource_dir.join(name);
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.into_os_string(),
|
||||
prefix_args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err("Suwayomi server binary not found. Please set the path in Settings.".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<ServerState>();
|
||||
{
|
||||
let guard = state.0.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
println!("Server already running, skipping spawn.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Seed server.conf before launching so Suwayomi starts in headless mode.
|
||||
let data_dir = suwayomi_data_dir();
|
||||
seed_server_conf(&data_dir);
|
||||
|
||||
let invocation = resolve_server_binary(&binary, &app)?;
|
||||
let shell = app.shell();
|
||||
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
|
||||
// Build the full arg list: prefix_args (e.g. -jar foo.jar) + rootDir flag.
|
||||
let args: Vec<String> = invocation.prefix_args.into_iter().chain(std::iter::once(rootdir_flag)).collect();
|
||||
|
||||
// On Windows, set the working directory to the bundle folder so javaw.exe
|
||||
// can resolve the JRE and jar relative paths correctly.
|
||||
let cmd = shell
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&args)
|
||||
.current_dir(invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()));
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
println!("Spawned server: {:?}", invocation.bin);
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn {:?}: {}", invocation.bin, e);
|
||||
Err(e.to_string())
|
||||
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();
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
kill_tachidesk(&app);
|
||||
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_discord_rpc::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_scale_factor,
|
||||
commands::storage::get_storage_info,
|
||||
commands::storage::get_default_downloads_path,
|
||||
commands::storage::check_path_exists,
|
||||
commands::storage::create_directory,
|
||||
commands::storage::migrate_downloads,
|
||||
commands::server::spawn_server,
|
||||
commands::server::kill_server,
|
||||
commands::system::get_platform_ui_scale,
|
||||
commands::system::restart_app,
|
||||
commands::system::exit_app,
|
||||
commands::system::clear_moku_cache,
|
||||
commands::system::clear_suwayomi_cache,
|
||||
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 {
|
||||
kill_tachidesk(window.app_handle());
|
||||
server::kill_tachidesk(window.app_handle());
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running moku");
|
||||
.expect("error while running moku")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "preview"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
if !conf_path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
return;
|
||||
}
|
||||
let initial = patch_conf_key(
|
||||
DEFAULT_SERVER_CONF.to_string(),
|
||||
"server.webUIEnabled",
|
||||
if web_ui_enabled { "true" } else { "false" },
|
||||
);
|
||||
if let Err(e) = std::fs::write(&conf_path, initial) {
|
||||
eprintln!("Could not write server.conf: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let patched = patch_conf_key(
|
||||
patch_conf_key(
|
||||
patch_conf_key(
|
||||
contents,
|
||||
"server.webUIEnabled",
|
||||
if web_ui_enabled { "true" } else { "false" },
|
||||
),
|
||||
"server.initialOpenInBrowserEnabled",
|
||||
"false",
|
||||
),
|
||||
"server.systemTrayEnabled",
|
||||
"false",
|
||||
);
|
||||
|
||||
let _ = std::fs::write(&conf_path, patched);
|
||||
}
|
||||
|
||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||
let replacement = format!("{key} = {value}");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||
let mut out = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = text;
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&replacement);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
pub mod conf;
|
||||
pub mod resolve;
|
||||
|
||||
use std::io::Write;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::ServerState;
|
||||
|
||||
pub use resolve::SpawnError;
|
||||
|
||||
pub fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||
eprintln!("{}", msg);
|
||||
if let Some(f) = log {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
if let Some(child) = state.0.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.status();
|
||||
|
||||
for _ in 0..30 {
|
||||
let still_running = std::process::Command::new("tasklist")
|
||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !still_running {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-f", "tachidesk"])
|
||||
.status();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use crate::server::do_log;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum SpawnError {
|
||||
NotConfigured(String),
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
pub struct ServerInvocation {
|
||||
pub bin: String,
|
||||
pub args: Vec<String>,
|
||||
pub working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn suwayomi_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||
.join("Tachidesk")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||
.join("Tachidesk")
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||
base.join("Tachidesk")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn java_bin_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||
}
|
||||
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
|
||||
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
fn data_root_args() -> Vec<String> {
|
||||
vec!["--dataRoot".to_string(), suwayomi_data_dir().to_string_lossy().into_owned()]
|
||||
}
|
||||
|
||||
fn jar_data_root_flag() -> String {
|
||||
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
|
||||
}
|
||||
|
||||
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
|
||||
ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec![
|
||||
jar_data_root_flag(),
|
||||
"-jar".to_string(),
|
||||
jar.to_string_lossy().into_owned(),
|
||||
],
|
||||
working_dir: Some(working_dir),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_server_binary(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
log: &mut Option<std::fs::File>,
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary={:?}", binary));
|
||||
|
||||
if !binary.trim().is_empty() {
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
|
||||
if path.exists() {
|
||||
let working_dir = path.parent().map(|p| p.to_path_buf());
|
||||
return Ok(ServerInvocation {
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
args: data_root_args(),
|
||||
working_dir,
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||
let p = bin_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: data_root_args(),
|
||||
working_dir: Some(bin_dir.to_path_buf()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let resource_dir = {
|
||||
let raw = app.path().resource_dir().unwrap_or_default();
|
||||
let stripped = strip_unc(raw);
|
||||
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
|
||||
stripped
|
||||
};
|
||||
|
||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||
|
||||
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||
|
||||
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
|
||||
if jar.exists() {
|
||||
do_log(log, "[resolve] using bundled JRE + jar");
|
||||
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||
}
|
||||
}
|
||||
|
||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: data_root_args(),
|
||||
working_dir: Some(resource_dir.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(jar_invocation(java, jar_path, resource_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||
|
||||
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
|
||||
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||
|
||||
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
|
||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
|
||||
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
|
||||
|
||||
if java.exists() && jar.exists() {
|
||||
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
|
||||
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||
}
|
||||
|
||||
if launcher_sh.exists() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
|
||||
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
|
||||
return Ok(ServerInvocation {
|
||||
bin: launcher_sh.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
}
|
||||
|
||||
if java.exists() && launcher_jar.exists() {
|
||||
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
|
||||
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
|
||||
}
|
||||
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let resolved = std::process::Command::new("where")
|
||||
.arg(name)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let resolved = std::process::Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||
|
||||
if let Some(bin_path) = resolved {
|
||||
do_log(log, &format!("[resolve] found on PATH: {:?}", bin_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: bin_path,
|
||||
args: vec![],
|
||||
working_dir: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(SpawnError::NotConfigured(
|
||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Moku",
|
||||
"version": "0.3.0",
|
||||
"identifier": "dev.moku.app",
|
||||
"version": "0.10.0",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
"beforeBuildCommand": "pnpm build:static"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
@@ -17,7 +18,8 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
"decorations": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -26,14 +28,22 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage"],
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"build": {
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "pnpm dev"
|
||||
},
|
||||
"app": {
|
||||
@@ -9,5 +8,8 @@
|
||||
"devtools": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"externalBin": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["appimage", "deb"],
|
||||
"externalBin": [
|
||||
"binaries/suwayomi-launcher-linux"
|
||||
],
|
||||
"resources": {
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||
"binaries/suwayomi-bundle/jre": "jre"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"targets": ["dmg"],
|
||||
"externalBin": [
|
||||
"binaries/suwayomi-server"
|
||||
],
|
||||
"resources": {
|
||||
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"exceptionDomain": "localhost",
|
||||
"frameworks": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import MangaPreview from "./components/explore/MangaPreview";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import Toaster from "./components/layout/Toaster";
|
||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import s from "./App.module.css";
|
||||
|
||||
const MAX_ATTEMPTS = 30;
|
||||
|
||||
export default function App() {
|
||||
const activeChapter = useStore((s) => s.activeChapter);
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
|
||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
||||
// appReady = ring filled + transition done, show main UI
|
||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [idle, setIdle] = useState(false);
|
||||
// dev tools: force show splash
|
||||
const [devSplash, setDevSplash] = useState(false);
|
||||
|
||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const idleRef = useRef(false);
|
||||
|
||||
// expose devSplash trigger via window for settings
|
||||
useEffect(() => {
|
||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
||||
return () => { delete (window as any).__mokuShowSplash; };
|
||||
}, []);
|
||||
|
||||
// Keep idleRef in sync so resetIdle can check it without a stale closure
|
||||
useEffect(() => { idleRef.current = idle; }, [idle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function resetIdle() {
|
||||
// While the idle splash is visible, don't reset — let SplashScreen's own
|
||||
// dismiss flow handle teardown so the exit animation plays fully.
|
||||
if (idleRef.current) return;
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||
if (idleTimeoutMs === 0) return;
|
||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
||||
}
|
||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
||||
resetIdle();
|
||||
return () => {
|
||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||
};
|
||||
}, [appReady, settings.idleTimeoutMin]);
|
||||
|
||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||
for (const item of prev) {
|
||||
if (item.state !== "DOWNLOADING") continue;
|
||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||
const manga = item.chapter.manga;
|
||||
addToast({ kind:"success", title:"Chapter downloaded",
|
||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||
duration: 4000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyQueue(next: DownloadQueueItem[]) {
|
||||
detectCompletions(prevQueueRef.current, next);
|
||||
prevQueueRef.current = next;
|
||||
setActiveDownloads(next.map(item => ({
|
||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||
})));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = settings.theme ?? "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const p = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", p);
|
||||
return () => document.removeEventListener("contextmenu", p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoStartServer) return;
|
||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
||||
console.warn("Could not start server:", err));
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Poll until server responds
|
||||
useEffect(() => {
|
||||
if (serverProbeOk) return;
|
||||
let cancelled = false, tries = 0;
|
||||
async function probe() {
|
||||
if (cancelled) return;
|
||||
tries++;
|
||||
try {
|
||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ query:"{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
||||
} catch {}
|
||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
||||
if (!cancelled) setTimeout(probe, 800);
|
||||
}
|
||||
const t = setTimeout(probe, 800);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appReady) return;
|
||||
function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||
}
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [appReady]);
|
||||
|
||||
useEffect(() => {
|
||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
||||
return () => { unsub.then(fn => fn()); };
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
||||
if (devSplash) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showFps
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading splash — shown until ring fills + transition completes
|
||||
if (!appReady) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
ringFull={serverProbeOk}
|
||||
failed={failed}
|
||||
showCards={settings.splashCards ?? true}
|
||||
onReady={() => setAppReady(true)}
|
||||
onRetry={() => {
|
||||
setFailed(false);
|
||||
setServerProbeOk(false);
|
||||
setRetryKey(k => k+1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{idle && !activeChapter && (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
)}
|
||||
{!activeChapter && <TitleBar/>}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader/> : <Layout/>}
|
||||
</div>
|
||||
{settingsOpen && <Settings/>}
|
||||
<MangaPreview/>
|
||||
<Toaster/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
const __APP_VERSION__: string
|
||||
}
|
||||
export {}
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 500.000000 500.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,500.000000) scale(0.050000,-0.050000)"
|
||||
fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,83 +0,0 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-1);
|
||||
min-width: 190px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0,0,0,0.08),
|
||||
0 4px 12px rgba(0,0,0,0.35),
|
||||
0 16px 40px rgba(0,0,0,0.25);
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 5px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled),
|
||||
.itemFocused:not(:disabled) {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Icon area — fixed-width column so labels align */
|
||||
.itemIconWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:hover .itemIconWrap,
|
||||
.itemFocused .itemIconWrap {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Danger variant */
|
||||
.itemDanger { color: var(--color-error); }
|
||||
.itemDanger:hover:not(:disabled),
|
||||
.itemDanger.itemFocused:not(:disabled) {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
||||
|
||||
/* Disabled */
|
||||
.itemDisabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 3px var(--sp-1);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import s from "./ContextMenu.module.css";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: never;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
separator: true;
|
||||
label?: never;
|
||||
icon?: never;
|
||||
onClick?: never;
|
||||
danger?: never;
|
||||
disabled?: never;
|
||||
}
|
||||
|
||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState<number>(-1);
|
||||
|
||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
||||
const actionable = items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocused((prev) => {
|
||||
const cur = actionable.indexOf(prev);
|
||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && focused >= 0) {
|
||||
e.preventDefault();
|
||||
const item = items[focused] as ContextMenuItem;
|
||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose, focused, actionable, items]);
|
||||
|
||||
// Focus first item on open
|
||||
useEffect(() => {
|
||||
if (actionable.length) setFocused(actionable[0]);
|
||||
}, []);
|
||||
|
||||
const getPosition = useCallback(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||
const scaledX = x / zoom;
|
||||
const scaledY = y / zoom;
|
||||
const menuW = 200;
|
||||
const menuH = items.length * 34;
|
||||
const vw = window.innerWidth / zoom;
|
||||
const vh = window.innerHeight / zoom;
|
||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
||||
}, [x, y, items.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={s.menu}
|
||||
style={getPosition()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if ("separator" in item && item.separator) {
|
||||
return <div key={i} className={s.separator} />;
|
||||
}
|
||||
const mi = item as ContextMenuItem;
|
||||
const isFocused = focused === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={[
|
||||
s.item,
|
||||
mi.danger ? s.itemDanger : "",
|
||||
mi.disabled ? s.itemDisabled : "",
|
||||
isFocused ? s.itemFocused : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||
onMouseLeave={() => setFocused(-1)}
|
||||
disabled={mi.disabled}
|
||||
>
|
||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||
{mi.icon ?? null}
|
||||
</span>
|
||||
<span className={s.itemLabel}>{mi.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.headerActions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
/* Loading state — accent tint so it's visually distinct */
|
||||
.iconBtnLoading {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.iconBtnLoading:hover:not(:disabled) {
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
|
||||
.statusDotActive {
|
||||
background: var(--accent);
|
||||
animation: pulse 1.6s ease infinite;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||
}
|
||||
|
||||
.rowActive { border-color: var(--accent-dim); }
|
||||
|
||||
/* Fade out rows being removed */
|
||||
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* Thumbnail */
|
||||
.thumb {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Info block */
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mangaTitle {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pagesLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* Right side */
|
||||
.rowRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stateLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
// Apply status to local state + global store.
|
||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||
setStatus(ds);
|
||||
setActiveDownloads(
|
||||
ds.queue.map((item) => ({
|
||||
chapterId: item.chapter.id,
|
||||
mangaId: item.chapter.mangaId,
|
||||
progress: item.progress,
|
||||
}))
|
||||
);
|
||||
}, [setActiveDownloads]);
|
||||
|
||||
async function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
setTogglingPlay(true);
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||
try {
|
||||
if (wasRunning) {
|
||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||
applyStatus(d.stopDownloader.downloadStatus);
|
||||
} else {
|
||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||
applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setTogglingPlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||
setStatus((prev) =>
|
||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||
);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
poll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll();
|
||||
} finally {
|
||||
setDequeueing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(chapterId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queue = status?.queue ?? [];
|
||||
const isRunning = status?.state === "STARTED";
|
||||
|
||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||
return Math.round(progress * pageCount);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
<button
|
||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
||||
title={isRunning ? "Pause" : "Resume"}
|
||||
>
|
||||
{togglingPlay ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : isRunning ? (
|
||||
<Pause size={14} weight="fill" />
|
||||
) : (
|
||||
<Play size={14} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={clear}
|
||||
disabled={clearing || queue.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
{clearing ? (
|
||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
||||
) : (
|
||||
<Trash size={14} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.statusBar}>
|
||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||
<span className={s.statusText}>
|
||||
{togglingPlay
|
||||
? (isRunning ? "Pausing…" : "Starting…")
|
||||
: isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
<span className={s.statusCount}>{queue.length} queued</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={s.empty}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className={s.empty}>Queue is empty.</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isRemoving = dequeueing.has(item.chapter.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.chapter.id}
|
||||
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||
>
|
||||
{manga?.thumbnailUrl && (
|
||||
<div className={s.thumb}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.thumbImg}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<div
|
||||
className={s.progressBar}
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.rowRight}>
|
||||
<span className={s.stateLabel}>{item.state}</span>
|
||||
{!isActive && (
|
||||
<button
|
||||
className={s.removeBtn}
|
||||
onClick={() => dequeue(item.chapter.id)}
|
||||
disabled={isRemoving}
|
||||
title="Remove from queue"
|
||||
>
|
||||
{isRemoving
|
||||
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||
: <X size={12} weight="light" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* Source picker */
|
||||
.sourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sourcePickerLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--t-base);
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) 0 var(--sp-6);
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionTitleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.seeAll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.seeAll:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.row::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
||||
.card {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fg);
|
||||
border-radius: 0 2px 0 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Ghost card — invisible placeholder to fill row trailing space */
|
||||
.ghostCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||
|
||||
.coverSkeleton {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 11px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||
.drillRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.drillHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.drillTitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.drillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.drillCard {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||
.drillCard:hover .title { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||
.noSource {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||
.exploreMoreCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
padding: 0;
|
||||
}
|
||||
.exploreMoreCard:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||
|
||||
.exploreMoreInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exploreMoreIcon {
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.exploreMoreLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exploreMoreGenre {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import GenreDrillPage from "./GenreDrillPage";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "../sources/SourceList";
|
||||
import SourceBrowse from "../sources/SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency score ────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
const ROW_CAP = 25;
|
||||
|
||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget;
|
||||
const canScrollLeft = el.scrollLeft > 0;
|
||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
if (!canScrollLeft && !canScrollRight) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
||||
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MiniCard = memo(function MiniCard({
|
||||
manga, onClick, onContextMenu, subtitle, progress,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
subtitle?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||
|
||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||
genre, onClick,
|
||||
}: { genre: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||
<div className={s.exploreMoreInner}>
|
||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title, icon, onSeeAll, loading, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||
loading?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
const genreFilter = useStore((s) => s.genreFilter);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
if (genreFilter) return <GenreDrillPage />;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||
{mode === "sources" && <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
|
||||
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
|
||||
const EXPLORE_ALL_MANGA = `
|
||||
query ExploreAllManga {
|
||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Fast genre row query against the local DB
|
||||
const MANGAS_BY_GENRE_EXPLORE = `
|
||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
||||
mangas(
|
||||
filter: { genre: { includesInsensitive: $genre } }
|
||||
first: $first
|
||||
orderBy: IN_LIBRARY_AT
|
||||
orderByType: DESC
|
||||
) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function ExploreFeed() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
const history = useStore((s) => s.history);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
||||
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
||||
const folders = useStore((s) => s.settings.folders);
|
||||
const addFolder = useStore((s) => s.addFolder);
|
||||
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Data load ─────────────────────────────────────────────────────────────
|
||||
// Library + genre rows: single local DB query each — instant, no source calls.
|
||||
// Popular: still needs fetchSourceManga since there's no local equivalent.
|
||||
useEffect(() => {
|
||||
const alreadyLoaded = allManga.length > 0;
|
||||
if (alreadyLoaded) return;
|
||||
|
||||
setLoadingLib(true);
|
||||
setLoadingPopular(true);
|
||||
setLoadError(false);
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
if (retryCount > 0) {
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.SOURCES);
|
||||
fetchedGenresRef.current = "";
|
||||
}
|
||||
|
||||
// Single query for all manga — library flag included
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
|
||||
.then((d) => d.mangas.nodes)
|
||||
).then(setAllManga)
|
||||
.catch((e) => { console.error(e); setLoadError(true); })
|
||||
.finally(() => setLoadingLib(false));
|
||||
|
||||
// Sources — only needed for Popular section
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then((allSources) => {
|
||||
if (allSources.length === 0) { setLoadingPopular(false); return; }
|
||||
const topSources = getTopSources(allSources).slice(0, 2);
|
||||
setSources(allSources);
|
||||
|
||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
||||
Promise.allSettled(
|
||||
topSources.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||
}).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
||||
})
|
||||
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
||||
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
// ── Frecency genres (derived from history + library) ──────────────────────
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
for (const entry of history) {
|
||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||
}
|
||||
const genreWeights = new Map<string, number>();
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
for (const [mangaId, count] of mangaScores.entries()) {
|
||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||
}
|
||||
if (genreWeights.size === 0)
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
||||
return Array.from(genreWeights.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
}, [allManga, history]);
|
||||
|
||||
// ── Genre rows: query local DB directly ─────────────────────────────────
|
||||
// One query per genre against the local mangas table — instant, no source I/O.
|
||||
useEffect(() => {
|
||||
if (frecencyGenres.length === 0 || allManga.length === 0) return;
|
||||
|
||||
const genreKey = frecencyGenres.join(",");
|
||||
if (fetchedGenresRef.current === genreKey) return;
|
||||
fetchedGenresRef.current = genreKey;
|
||||
|
||||
setLoadingGenres(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const streamingMap = new Map<string, Manga[]>();
|
||||
|
||||
Promise.allSettled(
|
||||
frecencyGenres.map((genre) =>
|
||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(
|
||||
MANGAS_BY_GENRE_EXPLORE,
|
||||
{ genre, first: 25 },
|
||||
ctrl.signal,
|
||||
).then((d) => d.mangas.nodes)
|
||||
).then((mangas) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
streamingMap.set(genre, mangas);
|
||||
setGenreResults(new Map(streamingMap));
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
||||
}, [frecencyGenres, allManga]);
|
||||
|
||||
function openManga(m: Manga) { setPreviewManga(m); }
|
||||
|
||||
// ── Continue reading ──────────────────────────────────────────────────────
|
||||
const continueReading = useMemo(() => {
|
||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||
const seen = new Set<number>();
|
||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||
for (const entry of history) {
|
||||
if (seen.has(entry.mangaId)) continue;
|
||||
seen.add(entry.mangaId);
|
||||
const manga = mangaMap.get(entry.mangaId);
|
||||
if (!manga) continue;
|
||||
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
||||
if (result.length >= 12) break;
|
||||
}
|
||||
return result;
|
||||
}, [history, allManga]);
|
||||
|
||||
// ── Recommended ───────────────────────────────────────────────────────────
|
||||
const recommended = useMemo(() => {
|
||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||
return allManga
|
||||
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||
.slice(0, 20);
|
||||
}, [allManga, frecencyGenres, continueReading]);
|
||||
|
||||
const genresLoading = loadingGenres;
|
||||
|
||||
return (
|
||||
<div className={s.body}>
|
||||
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{items.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{items.length >= ROW_CAP && (
|
||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||
)}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
{loadError ? (
|
||||
<>
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
.title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Grid fills entire remaining height, no show-more needed */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
/* Smooth GPU-accelerated scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
/* Solid bg shown while image fades in — matches skeleton color */
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.inLibraryBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.cardSkeleton { padding: 0; }
|
||||
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
||||
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Show more — spans full grid width */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./GenreDrillPage.module.css";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
const INITIAL_PAGES = 3;
|
||||
const MAX_SOURCES = 12;
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
|
||||
* multi-tag string ("Action+Romance"). Parse it into an array.
|
||||
*
|
||||
* Callers set multi-tag filters via:
|
||||
* setGenreFilter("Action+Romance")
|
||||
*
|
||||
* The Explore feed's "See all" button continues to pass single strings and
|
||||
* requires no change.
|
||||
*/
|
||||
function parseTags(genreFilter: string): string[] {
|
||||
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
|
||||
function tagsLabel(tags: string[]): string {
|
||||
if (tags.length === 1) return tags[0];
|
||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side AND filter.
|
||||
* Sources only accept a single query string, so we send the first tag and
|
||||
* drop results that don't also have the remaining tags in their genre list.
|
||||
*/
|
||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||
return tags.every((t) => genres.includes(t.toLowerCase()));
|
||||
}
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): 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));
|
||||
}
|
||||
|
||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||
export default function GenreDrillPage() {
|
||||
const genreFilter = useStore((st) => st.genreFilter);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const settings = useStore((st) => st.settings);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
|
||||
// Parse the filter string into individual tags
|
||||
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
|
||||
// First tag is sent as the source query string (sources accept only one term)
|
||||
const primaryTag = tags[0] ?? "";
|
||||
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
|
||||
// Per-source next-page tracker; -1 means exhausted
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (tags.length === 0) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setLoadingInitial(true);
|
||||
setSourceManga([]);
|
||||
setLibraryManga([]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// ── Library (local DB, instant) ───────────────────────────────────────
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
)
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||
|
||||
// ── Sources: stream results as each source responds ───────────────────
|
||||
// Source list is stable within a session — cache indefinitely.
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const sources = allSources.slice(0, MAX_SOURCES);
|
||||
sourcesRef.current = sources;
|
||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||
|
||||
await runConcurrent(sources, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
|
||||
// On navigation-away → back the pages are still in the TTL store, so fetchPage
|
||||
// returns the cached promise immediately without hitting the network.
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageItems: Manga[] = [];
|
||||
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
const result = await cache
|
||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
)
|
||||
.catch((e: any) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
|
||||
ps.add(page);
|
||||
|
||||
// For multi-tag searches: client-side AND filter for tags beyond the first.
|
||||
// Sources only support a single query string, so we send primaryTag and
|
||||
// drop results that don't contain the remaining tags in their genre array.
|
||||
const matching = tags.length > 1
|
||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||
: result.mangas;
|
||||
|
||||
pageItems.push(...matching);
|
||||
|
||||
if (!result.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
} else if (page === INITIAL_PAGES) {
|
||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
}).catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
});
|
||||
|
||||
return () => { ctrl.abort(); };
|
||||
// genreFilter (not tags) as the dep — tags is derived from it and would
|
||||
// cause an extra render on every parse; genreFilter is the stable identity.
|
||||
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Derived merged list ───────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
// For multi-tag: library results must match ALL tags
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
|
||||
return dedupeMangaById([...libMatches, ...srcOnly]);
|
||||
}, [libraryManga, sourceManga, tags]);
|
||||
|
||||
// ── Load more ─────────────────────────────────────────────────────────────
|
||||
const hasMoreVisible = visibleCount < filtered.length;
|
||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
|
||||
// Fast path: buffered results already in memory
|
||||
if (hasMoreVisible) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: fetch next pages from sources
|
||||
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
if (!sources.length) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sources, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
|
||||
const result = await cache
|
||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
)
|
||||
.catch((e: any) => {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
|
||||
ps.add(page);
|
||||
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
|
||||
const matching = tags.length > 1
|
||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
||||
: result.mangas;
|
||||
|
||||
if (matching.length > 0)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...folders.map((f): ContextMenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const visibleItems = filtered.slice(0, visibleCount);
|
||||
const label = tagsLabel(tags);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{label}</span>
|
||||
{loadingInitial && filtered.length === 0 ? null : (
|
||||
<span className={s.resultCount}>
|
||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||
</span>
|
||||
)}
|
||||
{!loadingInitial && hasMoreNetwork && (
|
||||
<span className={s.loadingHint}>More loading…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingInitial && filtered.length === 0 ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No manga found for "{label}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{visibleItems.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading…</>
|
||||
: "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||