Compare commits
210 Commits
v0.8.3
..
1e159bbd73
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,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,182 +4,133 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: latest }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
with: { name: frontend-dist, path: dist/, retention-days: 1 }
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload dist
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: dist/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (macOS)
|
name: Tauri (macOS)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
run: |
|
||||||
with:
|
source .github/read_versions.sh
|
||||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
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: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
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
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: latest }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install JS dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Download Suwayomi binaries
|
- name: Download Suwayomi binaries
|
||||||
run: |
|
run: |
|
||||||
download_suwayomi() {
|
dl() {
|
||||||
local asset="$1" sha="$2" outdir="$3"
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/${asset}" \
|
||||||
-o "${outdir}.tar.gz"
|
-o "${outdir}.tar.gz"
|
||||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
mkdir -p "${outdir}"
|
mkdir -p "${outdir}"
|
||||||
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
}
|
}
|
||||||
|
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-arm64.tar.gz" "$SUWA_HASH_ARM64" suwayomi-arm64
|
||||||
download_suwayomi \
|
dl "Suwayomi-Server-v${SUWA_VERSION}-macOS-x64.tar.gz" "$SUWA_HASH_X64" suwayomi-x64
|
||||||
"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"
|
|
||||||
|
|
||||||
- name: Stage Suwayomi sidecars
|
- name: Stage Suwayomi sidecars
|
||||||
run: |
|
run: |
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
|
stage() {
|
||||||
stage_arch() {
|
local srcdir="$1" arch="$2"
|
||||||
local srcdir="$1"
|
|
||||||
local arch="$2"
|
|
||||||
local sidecar="src-tauri/binaries/suwayomi-server-${arch}"
|
|
||||||
local bundle_dest="src-tauri/binaries/suwayomi-bundle-${arch}"
|
|
||||||
|
|
||||||
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
JAR=$(find "$srcdir" -name "Suwayomi-Server.jar" | head -1)
|
||||||
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | 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; }
|
||||||
if [ -z "$JAR" ]; then
|
[ -z "$JAVA" ] && { echo "ERROR: java not found in $srcdir"; find "$srcdir" -type f | head -30; exit 1; }
|
||||||
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
cp -r "$srcdir" "src-tauri/binaries/suwayomi-bundle-${arch}"
|
||||||
find "$srcdir" -type f | head -30
|
cp src-tauri/binaries/suwayomi-launcher.sh "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
exit 1
|
chmod +x "src-tauri/binaries/suwayomi-server-${arch}"
|
||||||
fi
|
|
||||||
if [ -z "$JAVA" ]; then
|
|
||||||
echo "ERROR: jre/bin/java not found in $srcdir"
|
|
||||||
find "$srcdir" -type f | head -30
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${arch}: jar=${JAR} java=${JAVA}"
|
|
||||||
|
|
||||||
cp -r "$srcdir" "$bundle_dest"
|
|
||||||
|
|
||||||
# The launcher script is committed at src-tauri/binaries/suwayomi-launcher.sh
|
|
||||||
# to avoid embedding a heredoc in YAML (which breaks GitHub Actions parsing).
|
|
||||||
cp src-tauri/binaries/suwayomi-launcher.sh "$sidecar"
|
|
||||||
chmod +x "$sidecar"
|
|
||||||
echo "Staged sidecar: $sidecar"
|
|
||||||
}
|
}
|
||||||
|
stage suwayomi-arm64 aarch64-apple-darwin
|
||||||
stage_arch suwayomi-arm64 aarch64-apple-darwin
|
stage suwayomi-x64 x86_64-apple-darwin
|
||||||
stage_arch suwayomi-x64 x86_64-apple-darwin
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
run: |
|
run: |
|
||||||
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
# ── aarch64 build ──────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for aarch64
|
|
||||||
run: |
|
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
|
||||||
src-tauri/binaries/suwayomi-bundle
|
|
||||||
|
|
||||||
- name: Build Tauri app (aarch64)
|
- name: Build Tauri app (aarch64)
|
||||||
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
|
||||||
env:
|
|
||||||
# Ad-hoc signing ("-") ships without a Developer ID.
|
|
||||||
# Gatekeeper will quarantine the app on other Macs — users must run:
|
|
||||||
# xattr -rd com.apple.quarantine Moku.app
|
|
||||||
# To fix this properly, set APPLE_SIGNING_IDENTITY to your
|
|
||||||
# "Developer ID Application: ..." cert name and add
|
|
||||||
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
|
||||||
# APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD secrets for notarisation.
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
|
||||||
|
|
||||||
# ── x86_64 build ───────────────────────────────────────────────────────
|
|
||||||
- name: Swap bundle for x86_64
|
|
||||||
run: |
|
run: |
|
||||||
rm -rf src-tauri/binaries/suwayomi-bundle
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin src-tauri/binaries/suwayomi-bundle
|
||||||
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)
|
- name: Build Tauri app (x86_64)
|
||||||
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
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:
|
env:
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
# ── upload artifacts ───────────────────────────────────────────────────
|
- name: Upload macOS artifacts to release
|
||||||
- name: Upload arm64 .dmg
|
env:
|
||||||
uses: actions/upload-artifact@v4
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
run: |
|
||||||
name: moku-macos-arm64-${{ github.event.inputs.version }}
|
for i in $(seq 1 12); do
|
||||||
path: src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
retention-days: 7
|
"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: Upload x64 .dmg
|
upload() {
|
||||||
uses: actions/upload-artifact@v4
|
curl -s -X POST \
|
||||||
with:
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
name: moku-macos-x64-${{ github.event.inputs.version }}
|
-H "Content-Type: application/octet-stream" \
|
||||||
path: src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
--data-binary @"$1" \
|
||||||
retention-days: 7
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to build (e.g. 0.4.0)"
|
description: "Version to build (e.g. 0.9.0)"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -16,147 +16,109 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: latest }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install dependencies
|
- run: pnpm build:static
|
||||||
run: pnpm install --frozen-lockfile
|
- uses: actions/upload-artifact@v4
|
||||||
|
with: { name: frontend-dist-windows, path: dist/, retention-days: 1 }
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload dist
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist-windows
|
|
||||||
path: dist/
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
tauri:
|
tauri:
|
||||||
name: Tauri (Windows x64)
|
name: Tauri (Windows x64)
|
||||||
needs: frontend
|
needs: frontend
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download frontend dist
|
- uses: actions/download-artifact@v4
|
||||||
uses: actions/download-artifact@v4
|
with: { name: frontend-dist-windows, path: dist/ }
|
||||||
with:
|
|
||||||
name: frontend-dist-windows
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Read versions
|
||||||
uses: dtolnay/rust-toolchain@stable
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
targets: x86_64-pc-windows-msvc
|
source .github/read_versions.sh
|
||||||
|
echo "SUWA_VERSION=$SUWA_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "SUWA_HASH=$SUWA_HASH_WINDOWS" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Rust cache
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
uses: Swatinem/rust-cache@v2
|
with: { targets: x86_64-pc-windows-msvc }
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with: { workspaces: src-tauri }
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with: { version: latest }
|
||||||
version: latest
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Install JS dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Download Suwayomi (Windows x64)
|
- name: Download Suwayomi (Windows x64)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${SUWA_VERSION}/Suwayomi-Server-v${SUWA_VERSION}-windows-x64.zip" \
|
||||||
-o suwayomi-windows.zip
|
-o suwayomi-windows.zip
|
||||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
echo "${SUWA_HASH} suwayomi-windows.zip" | sha256sum -c -
|
||||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
- name: Extract Suwayomi bundle
|
- name: Stage Suwayomi bundle
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mkdir -p suwayomi-extracted
|
mkdir -p suwayomi-extracted
|
||||||
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
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)
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
cp -r "$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)"/. suwayomi-extracted/
|
||||||
cp -r "$INNER"/. suwayomi-extracted/
|
|
||||||
else
|
else
|
||||||
cp -r suwayomi-raw/. suwayomi-extracted/
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Stage Suwayomi bundle
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
find suwayomi-extracted -path "*/jre/bin/java.exe" | grep -q . \
|
||||||
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
|| { echo "ERROR: java.exe not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
if [ -z "$JAVA" ]; then
|
find suwayomi-extracted -name "Suwayomi-Server.jar" | grep -q . \
|
||||||
echo "ERROR: jre/bin/java.exe not found"
|
|| { echo "ERROR: Suwayomi-Server.jar not found"; find suwayomi-extracted -type f | head -50; exit 1; }
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$JAR" ]; then
|
|
||||||
echo "ERROR: Suwayomi-Server.jar not found"
|
|
||||||
find suwayomi-extracted -type f | head -50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
- name: Validate staging
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \
|
|
||||||
| grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1)
|
|
||||||
find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \
|
|
||||||
| grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1)
|
|
||||||
echo "Staging OK"
|
|
||||||
|
|
||||||
- name: Patch tauri.conf.json for CI
|
- name: Patch tauri.conf.json for CI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
- name: Delete existing draft release if present
|
- name: Delete existing draft release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
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
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
echo "Deleting existing draft release $RELEASE_ID"
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
# Also delete the tag so tauri-action can recreate it
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
echo "Deleted draft release and tag"
|
|
||||||
else
|
|
||||||
echo "No existing draft release found"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build Tauri app + create draft release
|
- name: Build Tauri app + create draft release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
with:
|
||||||
tagName: v${{ github.event.inputs.version }}
|
tagName: v${{ github.event.inputs.version }}
|
||||||
releaseName: Moku v${{ github.event.inputs.version }}
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
Windows installer for Moku v${{ github.event.inputs.version }}.
|
Moku v${{ github.event.inputs.version }}
|
||||||
Download the `.exe` file below to install or update.
|
|
||||||
|
**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
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
# --- Build Artifacts ---
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
suwayomi-raw/
|
||||||
|
suwayomi-windows.zip
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# --- Nix ---
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
# --- Logs ---
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# --- IDEs & OS ---
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -30,14 +37,19 @@ yarn-error.log*
|
|||||||
*.sln
|
*.sln
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# --- Tauri specific ---
|
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
dist/
|
dist/
|
||||||
packaging/frontend-dist.tar.gz
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
./flatpak-builder
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.5.0
|
pkgver=0.9.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Youwes09/Moku"
|
url="https://github.com/moku-project/Moku"
|
||||||
license=('Apache 2.0')
|
license=('Apache-2.0')
|
||||||
depends=(
|
depends=(
|
||||||
'webkit2gtk-4.1'
|
'webkit2gtk-4.1'
|
||||||
'gtk3'
|
'gtk3'
|
||||||
@@ -13,28 +13,46 @@ depends=(
|
|||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
'rust'
|
'rust'
|
||||||
'cargo'
|
|
||||||
'nodejs'
|
|
||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
optdepends=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
'discord: Discord rich presence'
|
||||||
"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"
|
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() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
|
||||||
|
mkdir -p src-tauri/.cargo
|
||||||
|
cat > src-tauri/.cargo/config.toml << 'EOF'
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cflags="${fixed_cflags/-flto=auto/}"
|
||||||
|
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
|
||||||
|
CFLAGS="$fixed_cflags" \
|
||||||
|
CXXFLAGS="$fixed_cxxflags" \
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -46,14 +64,11 @@ package() {
|
|||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = false
|
||||||
@@ -64,22 +79,22 @@ server.autoDownloadNewChapters = false
|
|||||||
server.globalUpdateInterval = 12
|
server.globalUpdateInterval = 12
|
||||||
server.maxSourcesInParallel = 6
|
server.maxSourcesInParallel = 6
|
||||||
server.extensionRepos = []
|
server.extensionRepos = []
|
||||||
EOF
|
CONF
|
||||||
|
|
||||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
@@ -90,25 +105,25 @@ unset WAYLAND_DISPLAY
|
|||||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
export JAVA_TOOL_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 \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
-Dsun.awt.disablegui=true \
|
-Dsun.awt.disablegui=true \
|
||||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
EOF
|
LAUNCHER
|
||||||
|
|
||||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
install -Dm644 LICENSE \
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
[](https://github.com/Youwes09/Moku/releases/latest)
|

|
||||||
[](./LICENSE)
|
[](https://github.com/moku-project/Moku)
|
||||||
[](https://discord.gg/x97hj8zR72)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,12 +20,16 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
</div>
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
<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-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>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -42,9 +46,8 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
- **Automation** — pre-download titles automatically and optionally delete chapters after reading (accessible from Series Detail)
|
||||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
- **Discord Rich Presence** — shows manga title, current chapter, and elapsed timer in your Discord status; configurable in Settings → General
|
||||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
|
||||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
- **Auto-updates** — in-app update checker with silent background notifications
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
@@ -54,36 +57,55 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Flatpak (Linux, 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.
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flatpak install moku.flatpak
|
flatpak install io.github.moku_app.Moku
|
||||||
flatpak run dev.moku.app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
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
|
### Nix
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/Moku
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/Moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
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:
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
> ```bash
|
> ```bash
|
||||||
@@ -105,7 +127,7 @@ You can point Moku at any Suwayomi instance — local or remote — via **Settin
|
|||||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Youwes09/Moku
|
git clone https://github.com/moku-project/Moku
|
||||||
cd Moku
|
cd Moku
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
pnpm tauri:dev
|
||||||
@@ -126,9 +148,9 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [SvelteKit 2](https://kit.svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite 8](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Nixpkgs stdenv](https://nixos.org/manual/nixpkgs/stable/) | Nix builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,7 +158,7 @@ pnpm tauri:dev
|
|||||||
|
|
||||||
Questions, feedback, or just want to hang out — join the Discord.
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
[](https://discord.gg/x97hj8zR72)
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,4 @@
|
|||||||
Major Revisions:
|
Revival of the TODO List!!!!!
|
||||||
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
|
||||||
|
|
||||||
Minor Revisions:
|
|
||||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
|
||||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
|
||||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
|
||||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
|
||||||
|
|
||||||
|
|
||||||
|
- Reminder to Completely Test Settings
|
||||||
Priority Bugs:
|
|
||||||
- Fix Library-Refresh System (TESTING)
|
|
||||||
|
|
||||||
General/Misc Bugs:
|
|
||||||
- Fix Highlightable Elements
|
|
||||||
- Investigate "egl:failed to create dri2 screen"
|
|
||||||
- Check Fonts/Design on Flatpak
|
|
||||||
- Fix Delete-All Crash (Deletes All but Cripples App)
|
|
||||||
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
|
||||||
|
|
||||||
|
|
||||||
In-Progress:
|
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
|
||||||
- Working on 3D Display Cards
|
|
||||||
- Add Flathub Support (Pending Video)
|
|
||||||
|
|
||||||
- QOL Animations & Revamps
|
|
||||||
- Extensions QOL Animations
|
|
||||||
- Folders Slide
|
|
||||||
- Dropdown Formatting (Repositories, Etc)
|
|
||||||
- Extensions Revamps
|
|
||||||
- Fix Pill-Shaped Language Filter
|
|
||||||
- Fix ALL ALL EN Tag Issue
|
|
||||||
- Search QOL Animations
|
|
||||||
- Languages Dropdown Animations
|
|
||||||
- Search Revamps
|
|
||||||
- Custom Language Selector Modal
|
|
||||||
- Change Tab Selector to match Extensions & Library Folders (Design)
|
|
||||||
- Filter Genre should Filter Tags as well
|
|
||||||
- Tracking Revamp
|
|
||||||
- Completely Revamp Tracking
|
|
||||||
|
|
||||||
- Fix Search Folder Tabs (Right-Align)
|
|
||||||
|
|
||||||
|
|
||||||
Testing Bugs:
|
|
||||||
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
|
|
||||||
- Fix Library Folders (Uneven Padding + Bleed into Other Folders); Appears Constraints are Off
|
|
||||||
-
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
Before Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 940 KiB |
@@ -1,30 +1,15 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773857772,
|
|
||||||
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1778716662,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -35,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773821835,
|
"lastModified": 1780243769,
|
||||||
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -51,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772328832,
|
"lastModified": 1777168982,
|
||||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -66,7 +51,6 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
@@ -79,11 +63,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773975983,
|
"lastModified": 1780543271,
|
||||||
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -2,23 +2,27 @@
|
|||||||
description = "Moku — manga reader frontend for Suwayomi";
|
description = "Moku — manga reader frontend for Suwayomi";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
crane.url = "github:ipetkov/crane";
|
|
||||||
rust-overlay = {
|
rust-overlay = {
|
||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
inputs@{ flake-parts, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem =
|
||||||
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.8.0";
|
versions = import ./nix/versions.nix;
|
||||||
|
version = versions.moku;
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
@@ -26,11 +30,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [ "rust-src" "rust-analyzer" ];
|
extensions = [
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
gtk3
|
gtk3
|
||||||
@@ -46,247 +51,54 @@
|
|||||||
gsettings-desktop-schemas
|
gsettings-desktop-schemas
|
||||||
];
|
];
|
||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
src = lib.cleanSourceWith {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
filter = path: type:
|
filter =
|
||||||
let base = builtins.baseNameOf path;
|
path: type:
|
||||||
|
let
|
||||||
|
base = builtins.baseNameOf path;
|
||||||
in
|
in
|
||||||
(lib.hasInfix "/src" path)
|
(lib.hasInfix "/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/src" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/icons" path)
|
||||||
|
|| (lib.hasInfix "/src-tauri/capabilities" path)
|
||||||
|
|| (lib.hasInfix "/static" path)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|
|| base == "pnpm-workspace.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "vite.config.ts";
|
|| base == "vite.config.ts"
|
||||||
|
|| base == "svelte.config.js"
|
||||||
|
|| base == "Cargo.toml"
|
||||||
|
|| base == "Cargo.lock"
|
||||||
|
|| base == "build.rs"
|
||||||
|
|| base == "tauri.conf.json";
|
||||||
};
|
};
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { inherit versions; };
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
moku = pkgs.callPackage ./nix/moku.nix {
|
||||||
|
inherit lib pkgs rustToolchain runtimeLibs suwayomiServer version src versions;
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
appIcon = ./src/lib/assets/moku-icon.svg;
|
||||||
pname = "moku-frontend";
|
|
||||||
inherit version;
|
|
||||||
src = frontendSrc;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version versions; };
|
||||||
src = ./src-tauri;
|
|
||||||
filter = path: type:
|
|
||||||
(craneLib.filterCargoSources path type)
|
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|
||||||
};
|
|
||||||
|
|
||||||
commonArgs = {
|
|
||||||
src = cargoSrc;
|
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
|
||||||
strictDeps = true;
|
|
||||||
buildInputs = runtimeLibs;
|
|
||||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
|
||||||
preBuild = ''
|
|
||||||
cp -r ${frontend} ../dist
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
|
||||||
|
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
|
||||||
inherit cargoArtifacts;
|
|
||||||
meta.mainProgram = "moku";
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p "$out/share/applications"
|
|
||||||
cat > "$out/share/applications/moku.desktop" << EOF
|
|
||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Moku
|
|
||||||
Comment=Manga reader frontend for Suwayomi
|
|
||||||
Exec=$out/bin/moku
|
|
||||||
Icon=moku
|
|
||||||
Terminal=false
|
|
||||||
Categories=Graphics;Viewer;
|
|
||||||
Keywords=manga;comic;reader;suwayomi;
|
|
||||||
StartupWMClass=moku
|
|
||||||
EOF
|
|
||||||
|
|
||||||
for size in 32x32 128x128 256x256 512x512; do
|
|
||||||
src="icons/$size.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
for size in 128x128 256x256; do
|
|
||||||
src="icons/''${size}@2x.png"
|
|
||||||
[ -f "$src" ] && install -Dm644 "$src" \
|
|
||||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
|
||||||
done
|
|
||||||
|
|
||||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
|
||||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
|
||||||
|
|
||||||
wrapProgram $out/bin/moku \
|
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
|
||||||
pkgs.gsettings-desktop-schemas
|
|
||||||
pkgs.gtk3
|
|
||||||
]}" \
|
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
|
||||||
--set GDK_BACKEND wayland \
|
|
||||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
bumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
|
||||||
echo "Bumped to $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
flatpakScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-flatpak";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
gnused coreutils git
|
|
||||||
nodejs_22 pnpm
|
|
||||||
appstream flatpak-builder flatpak
|
|
||||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
|
||||||
];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
|
||||||
|
|
||||||
echo "── Bumping versions ──"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/tauri.conf.json"
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
|
||||||
"$REPO/src-tauri/Cargo.toml"
|
|
||||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
|
||||||
"$REPO/flake.nix"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building frontend ──"
|
|
||||||
cd "$REPO"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Repacking frontend-dist.tar.gz ──"
|
|
||||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
|
||||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
|
||||||
echo "sha256: $FRONTEND_SHA"
|
|
||||||
|
|
||||||
echo "── Patching manifest sha256 ──"
|
|
||||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
|
||||||
import re, sys
|
|
||||||
path, sha = sys.argv[1], sys.argv[2]
|
|
||||||
text = open(path).read()
|
|
||||||
updated, n = re.subn(
|
|
||||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
|
||||||
r'\g<1>' + sha, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Regenerating cargo-sources.json ──"
|
|
||||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
|
||||||
"$REPO/src-tauri/Cargo.lock" \
|
|
||||||
-o "$REPO/packaging/cargo-sources.json"
|
|
||||||
echo "Done"
|
|
||||||
|
|
||||||
echo "── Building flatpak ──"
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="$REPO/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"$REPO/build-dir" \
|
|
||||||
"$MANIFEST"
|
|
||||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
|
||||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
|
||||||
echo "moku.flatpak created"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done — v$VERSION"
|
|
||||||
echo " -> $REPO/moku.flatpak"
|
|
||||||
echo ""
|
|
||||||
echo "After pushing the tag, run:"
|
|
||||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-pkgbuild-bump";
|
|
||||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
|
||||||
text = ''
|
|
||||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
|
||||||
VERSION="$1"
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
PKGBUILD="$REPO/PKGBUILD"
|
|
||||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
|
||||||
|
|
||||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
|
||||||
echo "Fetching tarball sha256..."
|
|
||||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
|
||||||
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
|
||||||
|
|
||||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
|
||||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
|
||||||
|
|
||||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
tunnelScript = pkgs.writeShellApplication {
|
|
||||||
name = "moku-tunnel";
|
|
||||||
runtimeInputs = with pkgs; [ cloudflared ];
|
|
||||||
text = ''
|
|
||||||
PORT="''${1:-4567}"
|
|
||||||
cloudflared tunnel --url "http://localhost:$PORT"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
apps = {
|
packages = {
|
||||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
inherit moku suwayomiServer;
|
||||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
default = moku;
|
||||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
|
||||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
|
||||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
|
||||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
packages = {
|
apps = {
|
||||||
inherit moku frontend;
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
default = moku;
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
|
update = { type = "app"; program = "${scripts.update}/bin/moku-update"; };
|
||||||
|
flatpak = { type = "app"; program = "${scripts.flatpak}/bin/moku-flatpak"; };
|
||||||
|
tunnel = { type = "app"; program = "${scripts.tunnel}/bin/moku-tunnel"; };
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
@@ -297,22 +109,27 @@ EOF
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomiServer
|
||||||
cloudflared
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [
|
||||||
|
ps.aiohttp
|
||||||
|
ps.tomlkit
|
||||||
|
]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export NO_STRIP=true
|
export NO_STRIP=true
|
||||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Release:"
|
echo " nix run .#bump -- <ver> bump version + rebuild frontend"
|
||||||
echo " nix run .#bump -- <ver> bump versions only"
|
echo " git commit && git tag && git push"
|
||||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
echo " nix run .#update -- <ver> fetch hashes + patch all configs"
|
||||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
echo " nix run .#flatpak build flatpak bundle"
|
||||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
echo " nix run .#tunnel -- [port] expose local server via cloudflare"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: io.github.Youwes09.Moku
|
app-id: io.github.moku_project.Moku
|
||||||
runtime: org.gnome.Platform
|
runtime: org.gnome.Platform
|
||||||
runtime-version: '48'
|
runtime-version: '48'
|
||||||
sdk: org.gnome.Sdk
|
sdk: org.gnome.Sdk
|
||||||
@@ -32,6 +32,77 @@ build-options:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
|
- name: intltool
|
||||||
|
buildsystem: autotools
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||||
|
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||||
|
|
||||||
|
- name: libdbusmenu
|
||||||
|
buildsystem: autotools
|
||||||
|
build-options:
|
||||||
|
cflags: -Wno-error
|
||||||
|
env:
|
||||||
|
HAVE_VALGRIND_FALSE: '#'
|
||||||
|
HAVE_VALGRIND_TRUE: ''
|
||||||
|
config-opts:
|
||||||
|
- --with-gtk=3
|
||||||
|
- --disable-static
|
||||||
|
- --disable-dumper
|
||||||
|
- --disable-tests
|
||||||
|
- --disable-gtk-doc
|
||||||
|
- --disable-vala
|
||||||
|
- --disable-introspection
|
||||||
|
cleanup:
|
||||||
|
- /include
|
||||||
|
- /libexec
|
||||||
|
- /lib/pkgconfig
|
||||||
|
- /lib/*.la
|
||||||
|
- /share/doc
|
||||||
|
- /share/libdbusmenu
|
||||||
|
- /share/gtk-doc
|
||||||
|
- /share/gir-1.0
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||||
|
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||||
|
|
||||||
|
- name: libayatana-ido
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||||
|
tag: 0.10.3
|
||||||
|
|
||||||
|
- name: libayatana-indicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||||
|
tag: 0.9.4
|
||||||
|
|
||||||
|
- name: libayatana-appindicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
|
- -DENABLE_BINDINGS_VALA=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||||
|
tag: 0.5.93
|
||||||
|
- type: shell
|
||||||
|
commands:
|
||||||
|
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
|
||||||
|
|
||||||
- name: openjdk
|
- name: openjdk
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -52,9 +123,6 @@ modules:
|
|||||||
- type: inline
|
- type: inline
|
||||||
dest-filename: catch_abort.c
|
dest-filename: catch_abort.c
|
||||||
contents: |
|
contents: |
|
||||||
// Linux only:
|
|
||||||
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
|
|
||||||
|
|
||||||
#define _GNU_SOURCE
|
#define _GNU_SOURCE
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
@@ -95,12 +163,12 @@ modules:
|
|||||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = false
|
server.webUIEnabled = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
server.webUIFlavor = "WebUI"
|
server.webUIFlavor = "WebUI"
|
||||||
server.webUIChannel = "stable"
|
server.webUIChannel = "PREVIEW"
|
||||||
server.electronPath = ""
|
server.electronPath = ""
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -114,23 +182,20 @@ modules:
|
|||||||
cat > /app/bin/tachidesk-server << 'EOF'
|
cat > /app/bin/tachidesk-server << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# Seed conf on first run
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
@@ -155,8 +220,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.2.2196/Suwayomi-Server-v2.2.2196.jar
|
||||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
sha256: 8e7244c269456661a87705f746f0d87275770aa976bab7c6920e4d513e97c3f6
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -166,24 +231,24 @@ modules:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
- 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.Youwes09.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.Youwes09.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.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/Youwes09/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.8.0
|
tag: v0.9.4
|
||||||
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
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,156 @@
|
|||||||
|
{ pkgs, rustToolchain, version, versions }:
|
||||||
|
|
||||||
|
{
|
||||||
|
bump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-bump";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
gnused
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
rustToolchain
|
||||||
|
nodejs_22
|
||||||
|
pnpm
|
||||||
|
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
sed -i "s/moku = \"[^\"]*\"/moku = \"$VERSION\"/" "$REPO/nix/versions.nix"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" "$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build:static
|
||||||
|
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA_HEX=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
FRONTEND_SHA_SRI=$(echo "$FRONTEND_SHA_HEX" | xxd -r -p | base64 -w0 | sed 's/^/sha256-/')
|
||||||
|
|
||||||
|
sed -i "s|distHash = \"[^\"]*\"|distHash = \"$FRONTEND_SHA_HEX\"|" "$REPO/nix/versions.nix"
|
||||||
|
sed -i "s|distHashSri = \"[^\"]*\"|distHashSri = \"$FRONTEND_SHA_SRI\"|" "$REPO/nix/versions.nix"
|
||||||
|
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA_HEX" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#update -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
update = pkgs.writeShellApplication {
|
||||||
|
name = "moku-update";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl nix xxd ];
|
||||||
|
text = ''
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
VERSIONS="$REPO/nix/versions.nix"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
if [[ $# -ge 1 ]]; then
|
||||||
|
VERSION="$1"
|
||||||
|
else
|
||||||
|
VERSION=$(grep 'moku = "' "$VERSIONS" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" | awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
sed -i "s/gitCommit = \"[^\"]*\"/gitCommit = \"$COMMIT\"/" "$VERSIONS"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "s/tarballHash = \"[^\"]*\"/tarballHash = \"$TARBALL_SHA\"/" "$VERSIONS"
|
||||||
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
|
|
||||||
|
if [[ $# -ge 2 ]]; then
|
||||||
|
SUWA_VER="$2"
|
||||||
|
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.9.4";
|
||||||
|
|
||||||
|
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-fbiiu0tCd6qCtu+SIfw+aR8Yj2bFCnR3dQAIO4BvwfM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitDeps = {
|
||||||
|
tauri-plugin-discord-rpc = "sha256-xq0qyK2NrwSAFDhXo0vbvcygRD2/7uqBaLpqfpfxkrc=";
|
||||||
|
};
|
||||||
|
|
||||||
|
gitCommit = "239960683b6c7f1347e1798b0e179a8a46628728";
|
||||||
|
tarballHash = "";
|
||||||
|
}
|
||||||
@@ -1,31 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"version": "0.5.0",
|
"private": true,
|
||||||
|
"version": "0.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
},
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"dependencies": {
|
"build:static": "MOKU_TARGET=static vite build",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"build:node": "MOKU_TARGET=node vite build",
|
||||||
"@tauri-apps/plugin-http": "^2.5.8",
|
"build:android": "MOKU_TARGET=static vite build",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"tauri:build": "tauri build"
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"phosphor-svelte": "^3.1.0",
|
|
||||||
"svelte-spa-router": "^4.0.1",
|
|
||||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
|
||||||
"tauri-plugin-drpc": "^1.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"svelte": "^5.0.0",
|
"@sveltejs/kit": "^2.62.0",
|
||||||
"svelte-check": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"typescript": "^5.0.0",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"vite": "^5.0.0"
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.5.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
|
"@capacitor/browser": "^8.0.3",
|
||||||
|
"@capacitor/core": "^8.4.0",
|
||||||
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.9",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
|
"capacitor-native-biometric": "^4.2.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"phosphor-svelte": "^3.1.0",
|
||||||
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=io.github.Youwes09.Moku
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<component type="desktop-application">
|
<component type="desktop-application">
|
||||||
<id>io.github.Youwes09.Moku</id>
|
<id>io.github.moku_project.Moku</id>
|
||||||
<metadata_license>MIT</metadata_license>
|
<metadata_license>MIT</metadata_license>
|
||||||
<project_license>MIT</project_license>
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
@@ -19,30 +19,30 @@
|
|||||||
</p>
|
</p>
|
||||||
</description>
|
</description>
|
||||||
|
|
||||||
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
<caption>Home screen showing your manga library</caption>
|
<caption>Home screen showing your manga library</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
<caption>Built-in manga reader</caption>
|
<caption>Built-in manga reader</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
<caption>Discover new manga across hundreds of sources</caption>
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
<caption>Download manager</caption>
|
<caption>Download manager</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
<screenshot>
|
<screenshot>
|
||||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
<caption>Settings</caption>
|
<caption>Settings</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
@@ -54,11 +54,16 @@
|
|||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.8.0" date="2025-04-01">
|
<release version="0.9.0" date="2025-04-01">
|
||||||
<description>
|
<description>
|
||||||
<p>Latest release with improved stability and UI refinements.</p>
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
</description>
|
</description>
|
||||||
</release>
|
</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">
|
<release version="0.4.0" date="2025-03-22">
|
||||||
<description>
|
<description>
|
||||||
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.8.0"
|
version = "0.9.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -15,13 +15,13 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -32,6 +32,12 @@ urlencoding = "2"
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
reqwest = { version = "0.12", features = ["blocking"] }
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Security_Credentials_UI",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Moku",
|
"description": "Default permissions for Moku",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:app:allow-default-window-icon",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
@@ -26,18 +32,19 @@
|
|||||||
"core:window:allow-inner-position",
|
"core:window:allow-inner-position",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-scale-factor",
|
"core:window:allow-scale-factor",
|
||||||
"updater:default",
|
|
||||||
"updater:allow-check",
|
|
||||||
"updater:allow-download-and-install",
|
|
||||||
"process:default",
|
"process:default",
|
||||||
|
"process:allow-exit",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"http:default",
|
"http:default",
|
||||||
"http:allow-fetch",
|
"http:allow-fetch",
|
||||||
|
"store:default",
|
||||||
"discord-rpc:default",
|
"discord-rpc:default",
|
||||||
"discord-rpc:allow-connect",
|
"discord-rpc:allow-connect",
|
||||||
"discord-rpc:allow-disconnect",
|
"discord-rpc:allow-disconnect",
|
||||||
"discord-rpc:allow-set-activity",
|
"discord-rpc:allow-set-activity",
|
||||||
"discord-rpc:allow-clear-activity",
|
"discord-rpc:allow-clear-activity",
|
||||||
"discord-rpc:allow-is-running"
|
"discord-rpc:allow-is-running",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,746 +1,168 @@
|
|||||||
use std::path::PathBuf;
|
mod commands;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::Write;
|
use std::io::{Read, Write};
|
||||||
use sysinfo::Disks;
|
use std::net::{TcpListener, TcpStream};
|
||||||
use serde::Serialize;
|
use tauri::{
|
||||||
use tauri::{Manager, WindowEvent};
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
#[cfg(target_os = "windows")]
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
use tauri::Emitter;
|
Manager, WindowEvent,
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
};
|
||||||
use walkdir::WalkDir;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
|
||||||
struct ServerState(Mutex<Option<CommandChild>>);
|
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
#[derive(Serialize)]
|
const IPC_PORT: u16 = 47823;
|
||||||
pub struct StorageInfo {
|
const HANDSHAKE: &[u8] = b"MOKU:1\n";
|
||||||
manga_bytes: u64,
|
const FOCUS_CMD: &[u8] = b"focus\n";
|
||||||
total_bytes: u64,
|
|
||||||
free_bytes: u64,
|
fn do_quit(app: &tauri::AppHandle) {
|
||||||
path: String,
|
server::kill_tachidesk(app);
|
||||||
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
fn start_instance_listener(app: tauri::AppHandle) {
|
||||||
#[serde(tag = "kind", content = "message")]
|
std::thread::spawn(move || {
|
||||||
pub enum SpawnError {
|
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||||
NotConfigured(String),
|
|
||||||
SpawnFailed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
|
||||||
pub struct ReleaseInfo {
|
|
||||||
pub tag_name: String,
|
|
||||||
pub name: String,
|
|
||||||
pub body: String,
|
|
||||||
pub published_at: String,
|
|
||||||
pub html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
struct UpdateProgress {
|
|
||||||
downloaded: u64,
|
|
||||||
total: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
|
||||||
let s = path.to_string_lossy();
|
|
||||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
||||||
PathBuf::from(stripped)
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
|
||||||
if !downloads_path.trim().is_empty() {
|
|
||||||
return PathBuf::from(downloads_path.trim());
|
|
||||||
}
|
|
||||||
suwayomi_data_dir().join("downloads")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
|
||||||
let path = resolve_downloads_path(&downloads_path);
|
|
||||||
|
|
||||||
let manga_bytes = if path.exists() {
|
|
||||||
WalkDir::new(&path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter_map(|e| e.metadata().ok())
|
|
||||||
.filter(|m| m.is_file())
|
|
||||||
.map(|m| m.len())
|
|
||||||
.sum()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let stat_path = if path.exists() {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let disks = Disks::new_with_refreshed_list();
|
|
||||||
let disk = disks
|
|
||||||
.iter()
|
|
||||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
|
||||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
|
||||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
|
||||||
|
|
||||||
Ok(StorageInfo {
|
|
||||||
manga_bytes,
|
|
||||||
total_bytes: disk.total_space(),
|
|
||||||
free_bytes: disk.available_space(),
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_default_downloads_path() -> String {
|
|
||||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn check_path_exists(path: String) -> bool {
|
|
||||||
std::path::Path::new(path.trim()).is_dir()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn create_directory(path: String) -> Result<(), String> {
|
|
||||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let src_path = std::path::PathBuf::from(src.trim());
|
|
||||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
|
||||||
|
|
||||||
if !src_path.is_dir() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let total: u64 = WalkDir::new(&src_path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_type().is_file())
|
|
||||||
.count() as u64;
|
|
||||||
|
|
||||||
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
|
||||||
|
|
||||||
let mut done: u64 = 0;
|
|
||||||
|
|
||||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
|
||||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
|
||||||
let target = dst_path.join(rel);
|
|
||||||
|
|
||||||
if entry.file_type().is_dir() {
|
|
||||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
|
||||||
} else {
|
|
||||||
if let Some(parent) = target.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
|
||||||
done += 1;
|
|
||||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
|
||||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
|
||||||
window.scale_factor().unwrap_or(1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if let Some(child) = state.0.lock().unwrap().take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let _ = std::process::Command::new("taskkill")
|
|
||||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.status();
|
|
||||||
|
|
||||||
for _ in 0..30 {
|
|
||||||
let still_running = std::process::Command::new("tasklist")
|
|
||||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !still_running { break; }
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
|
||||||
server.port = 4567
|
|
||||||
server.webUIEnabled = false
|
|
||||||
server.initialOpenInBrowserEnabled = false
|
|
||||||
server.systemTrayEnabled = false
|
|
||||||
server.webUIInterface = "browser"
|
|
||||||
server.webUIFlavor = "WebUI"
|
|
||||||
server.webUIChannel = "stable"
|
|
||||||
server.electronPath = ""
|
|
||||||
server.debugLogsEnabled = false
|
|
||||||
server.downloadAsCbz = true
|
|
||||||
server.autoDownloadNewChapters = false
|
|
||||||
server.globalUpdateInterval = 12
|
|
||||||
server.maxSourcesInParallel = 6
|
|
||||||
server.extensionRepos = []
|
|
||||||
"#;
|
|
||||||
|
|
||||||
fn seed_server_conf(data_dir: &PathBuf) {
|
|
||||||
let conf_path = data_dir.join("server.conf");
|
|
||||||
|
|
||||||
if !conf_path.exists() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
|
||||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
|
||||||
return;
|
return;
|
||||||
|
};
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
handle_ipc_connection(stream, &app);
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
});
|
||||||
eprintln!("Could not write server.conf: {e}");
|
}
|
||||||
}
|
|
||||||
|
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let Ok(n) = stream.read(&mut buf) else { return };
|
||||||
|
let msg = &buf[..n];
|
||||||
|
|
||||||
|
if !msg.starts_with(HANDSHAKE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
|
let cmd = &msg[HANDSHAKE.len()..];
|
||||||
|
if cmd.starts_with(b"focus") {
|
||||||
let patched = patch_conf_key(
|
let _ = stream.write_all(b"ok\n");
|
||||||
patch_conf_key(
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
patch_conf_key(contents, "server.webUIEnabled", "false"),
|
let _ = win.show();
|
||||||
"server.initialOpenInBrowserEnabled", "false",
|
let _ = win.unminimize();
|
||||||
),
|
let _ = win.set_focus();
|
||||||
"server.systemTrayEnabled", "false",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::fs::write(&conf_path, patched);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
|
||||||
let replacement = format!("{key} = {value}");
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
|
|
||||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
|
||||||
let mut out = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
out.push('\n');
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = text;
|
|
||||||
if !out.ends_with('\n') { out.push('\n'); }
|
|
||||||
out.push_str(&replacement);
|
|
||||||
out.push('\n');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn suwayomi_data_dir() -> PathBuf {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
|
||||||
.join("moku\\tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
|
||||||
.join("io.github.Youwes09.Moku.app/tachidesk")
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
|
||||||
base.join("moku/tachidesk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ServerInvocation {
|
|
||||||
bin: String,
|
|
||||||
args: Vec<String>,
|
|
||||||
working_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
|
||||||
|
|
||||||
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
|
||||||
if java.exists() { Some(java) } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
|
||||||
eprintln!("{}", msg);
|
|
||||||
if let Some(f) = log {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_server_binary(
|
|
||||||
binary: &str,
|
|
||||||
app: &tauri::AppHandle,
|
|
||||||
log: &mut Option<std::fs::File>,
|
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
|
||||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
|
||||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
|
||||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
|
||||||
if path.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: path.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
do_log(log, "[resolve] user path not found, falling through");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
|
||||||
if let Some(bin_dir) = exe.parent() {
|
|
||||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
|
||||||
let p = bin_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
|
||||||
if p.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: Some(bin_dir.to_path_buf()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
fn signal_existing_instance() -> bool {
|
||||||
let resource_dir = {
|
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
|
||||||
let raw = app.path().resource_dir().unwrap_or_default();
|
return false;
|
||||||
let stripped = strip_unc(raw);
|
|
||||||
do_log(log, &format!("[resolve] resource_dir = {:?}", stripped));
|
|
||||||
stripped
|
|
||||||
};
|
};
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
let mut msg = Vec::new();
|
||||||
{
|
msg.extend_from_slice(HANDSHAKE);
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
msg.extend_from_slice(FOCUS_CMD);
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
if stream.write_all(&msg).is_err() {
|
||||||
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
return false;
|
||||||
|
|
||||||
match find_java_in_bundle(&bundle_dir, log) {
|
|
||||||
Some(java) if jar.exists() => {
|
|
||||||
do_log(log, "[resolve] using bundled JRE");
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
|
||||||
working_dir: Some(bundle_dir),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
let mut resp = [0u8; 4];
|
||||||
{
|
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
|
||||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
|
||||||
let p = resource_dir.join(name);
|
|
||||||
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
|
|
||||||
if p.exists() {
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: Some(resource_dir.clone()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
|
||||||
let jar = std::fs::read_dir(&resource_dir)
|
|
||||||
.ok()
|
|
||||||
.and_then(|mut rd| {
|
|
||||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
|
||||||
.and_then(|e| e.ok())
|
|
||||||
.map(|e| e.path())
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(jar_path) = jar {
|
|
||||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
|
||||||
working_dir: Some(resource_dir),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// Root of Moku.app/Contents/ — scan every subdirectory level by level.
|
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
|
||||||
let contents_dir = resource_dir
|
|
||||||
.parent() // Moku.app/Contents/
|
|
||||||
.unwrap_or(&resource_dir)
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
|
||||||
|
|
||||||
// Native-binary names we recognise (most specific first so arch-specific
|
|
||||||
// names win over the generic "suwayomi-server" if both somehow exist).
|
|
||||||
const NATIVE_NAMES: &[&str] = &[
|
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
|
||||||
"suwayomi-server",
|
|
||||||
"suwayomi-launcher",
|
|
||||||
"suwayomi-launcher.sh",
|
|
||||||
"tachidesk-server",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Collect every directory inside Contents/, grouped by depth so we
|
|
||||||
// search shallower levels first (BFS order via WalkDir min/max_depth).
|
|
||||||
// We go up to depth 8 which is more than enough for any real bundle.
|
|
||||||
let mut found_binary: Option<ServerInvocation> = None;
|
|
||||||
let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar)
|
|
||||||
|
|
||||||
'outer: for depth in 0u8..=8 {
|
|
||||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
|
||||||
.min_depth(depth as usize)
|
|
||||||
.max_depth(depth as usize)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_type().is_dir())
|
|
||||||
.map(|e| e.into_path())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for dir in &entries {
|
|
||||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
|
||||||
|
|
||||||
// 1. Look for a native server binary in this directory.
|
|
||||||
for name in NATIVE_NAMES {
|
|
||||||
let p = dir.join(name);
|
|
||||||
if p.exists() {
|
|
||||||
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
|
||||||
found_binary = Some(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: Some(dir.clone()),
|
|
||||||
});
|
|
||||||
break 'outer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Look for a JRE java binary paired with a .jar in the same
|
|
||||||
// or sibling directories. We record the first hit and keep
|
|
||||||
// scanning natives; if no native is ever found we fall back
|
|
||||||
// to this.
|
|
||||||
if found_java.is_none() {
|
|
||||||
let java_exe = dir.join("bin").join("java");
|
|
||||||
if java_exe.exists() {
|
|
||||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
|
||||||
// Search upward from the JRE dir for a .jar file.
|
|
||||||
let mut search = dir.as_path();
|
|
||||||
'jar: for _ in 0..5 {
|
|
||||||
if let Ok(rd) = std::fs::read_dir(search) {
|
|
||||||
for entry in rd.filter_map(|e| e.ok()) {
|
|
||||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
|
||||||
let jar = entry.path();
|
|
||||||
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
|
||||||
found_java = Some((java_exe.clone(), jar));
|
|
||||||
break 'jar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also look in a sibling `bin/` directory.
|
|
||||||
let bin_sibling = search.join("bin");
|
|
||||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
|
||||||
for entry in rd.filter_map(|e| e.ok()) {
|
|
||||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
|
||||||
let jar = entry.path();
|
|
||||||
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
|
|
||||||
found_java = Some((java_exe.clone(), jar));
|
|
||||||
break 'jar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match search.parent() {
|
|
||||||
Some(p) => search = p,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(inv) = found_binary {
|
|
||||||
return Ok(inv);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((java, jar)) = found_java {
|
|
||||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: java.to_string_lossy().into_owned(),
|
|
||||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
|
||||||
working_dir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
|
||||||
}
|
|
||||||
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
|
||||||
|
|
||||||
if found {
|
|
||||||
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(SpawnError::NotConfigured(
|
|
||||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
|
||||||
{
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
if state.0.lock().unwrap().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_dir = suwayomi_data_dir();
|
|
||||||
let log_path = data_dir.join("moku-spawn.log");
|
|
||||||
let _ = std::fs::create_dir_all(&data_dir);
|
|
||||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
|
||||||
|
|
||||||
seed_server_conf(&data_dir);
|
|
||||||
|
|
||||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
|
||||||
let rootdir_flag = format!(
|
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
|
||||||
data_dir.to_string_lossy()
|
|
||||||
);
|
|
||||||
invocation.args.insert(0, rootdir_flag);
|
|
||||||
}
|
|
||||||
|
|
||||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
|
||||||
|
|
||||||
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
|
||||||
|
|
||||||
let cmd = app.shell()
|
|
||||||
.command(&invocation.bin)
|
|
||||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
|
||||||
.args(&invocation.args)
|
|
||||||
.current_dir(&working_dir);
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok((_rx, child)) => {
|
|
||||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
|
||||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
kill_tachidesk(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
|
||||||
use tauri_plugin_http::reqwest;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("Moku")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("GitHub API returned {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct GhRelease {
|
|
||||||
tag_name: String,
|
|
||||||
name: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
published_at: Option<String>,
|
|
||||||
html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
|
||||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
|
||||||
tag_name: r.tag_name.clone(),
|
|
||||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
|
||||||
body: r.body.unwrap_or_default(),
|
|
||||||
published_at: r.published_at.unwrap_or_default(),
|
|
||||||
html_url: r.html_url,
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
|
||||||
|
|
||||||
let updater = app.updater().map_err(|e| e.to_string())?;
|
|
||||||
let update = updater.check().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let Some(update) = update else {
|
|
||||||
return Err("No update available.".into());
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
update
|
|
||||||
.download_and_install(
|
|
||||||
move |downloaded, total| {
|
|
||||||
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn restart_app(app: tauri::AppHandle) {
|
|
||||||
tauri::process::restart(&app.env());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn open_path(path: String) -> Result<(), String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let p = strip_unc(std::path::PathBuf::from(path.trim()));
|
|
||||||
std::process::Command::new("explorer")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let p = std::path::Path::new(path.trim());
|
|
||||||
std::process::Command::new("open")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
|
||||||
{
|
|
||||||
let p = std::path::Path::new(path.trim());
|
|
||||||
std::process::Command::new("xdg-open")
|
|
||||||
.arg(p)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|
||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
app.dialog()
|
|
||||||
.file()
|
|
||||||
.set_title("Choose Downloads Folder")
|
|
||||||
.blocking_pick_folder()
|
|
||||||
.map(|p| p.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
if signal_existing_instance() {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_discord_rpc::init())
|
.plugin(tauri_plugin_discord_rpc::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
commands::storage::get_storage_info,
|
||||||
get_default_downloads_path,
|
commands::storage::get_default_downloads_path,
|
||||||
check_path_exists,
|
commands::storage::check_path_exists,
|
||||||
create_directory,
|
commands::storage::create_directory,
|
||||||
migrate_downloads,
|
commands::storage::migrate_downloads,
|
||||||
spawn_server,
|
commands::server::spawn_server,
|
||||||
kill_server,
|
commands::server::kill_server,
|
||||||
get_platform_ui_scale,
|
commands::system::get_platform_ui_scale,
|
||||||
list_releases,
|
commands::system::restart_app,
|
||||||
download_and_install_update,
|
commands::system::exit_app,
|
||||||
restart_app,
|
commands::system::clear_moku_cache,
|
||||||
open_path,
|
commands::system::clear_suwayomi_cache,
|
||||||
pick_downloads_folder,
|
commands::system::reset_suwayomi_data,
|
||||||
|
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| {
|
.on_window_event(|window, event| {
|
||||||
if let WindowEvent::Destroyed = event {
|
if let WindowEvent::Destroyed = event {
|
||||||
kill_tachidesk(window.app_handle());
|
server::kill_tachidesk(window.app_handle());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku")
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
moku_lib::run();
|
moku_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.8.0",
|
"version": "0.9.4",
|
||||||
"identifier": "io.github.Youwes09.Moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build:static"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -27,9 +28,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [
|
"targets": ["nsis"],
|
||||||
"nsis"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -49,10 +48,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -13,4 +12,4 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"externalBin": []
|
"externalBin": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
{
|
{
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": true,
|
|
||||||
"resources": [
|
"resources": [
|
||||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||||
"binaries/suwayomi-bundle/jre/**/*"
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
|
||||||
],
|
|
||||||
"windows": {
|
|
||||||
"installMode": "passive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { store, setActiveDownloads } from "@store/state.svelte";
|
|
||||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
|
||||||
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
|
||||||
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
|
||||||
import { applyTheme } from "@core/theme";
|
|
||||||
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
|
||||||
import { checkForUpdateSilently } from "@core/updater";
|
|
||||||
import Layout from "@shared/chrome/Layout.svelte";
|
|
||||||
import Reader from "@features/reader/components/Reader.svelte";
|
|
||||||
import Settings from "@features/settings/components/Settings.svelte";
|
|
||||||
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
|
||||||
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
|
||||||
import Toaster from "@shared/chrome/Toaster.svelte";
|
|
||||||
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
|
||||||
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
|
||||||
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
void platform();
|
|
||||||
|
|
||||||
let appReady = $state(false);
|
|
||||||
let idle = $state(false);
|
|
||||||
let devSplash = $state(false);
|
|
||||||
|
|
||||||
let themeEditorOpen = $state(false);
|
|
||||||
let themeEditorEditId = $state<string | null>(null);
|
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
|
||||||
themeEditorEditId = id ?? null;
|
|
||||||
themeEditorOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeThemeEditor() {
|
|
||||||
themeEditorOpen = false;
|
|
||||||
themeEditorEditId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { void store.settings.theme; applyTheme(); });
|
|
||||||
$effect(() => { void store.settings.uiZoom; applyZoom(); });
|
|
||||||
$effect(() => mountZoomKey());
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
return mountIdleDetection(
|
|
||||||
() => { idle = true; },
|
|
||||||
() => { if (idle) idle = false; },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
const timer = setTimeout(checkForUpdateSilently, 5_000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (store.settings.discordRpc) {
|
|
||||||
initRpc();
|
|
||||||
} else {
|
|
||||||
clearReading();
|
|
||||||
destroyRpc();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!store.activeChapter && store.settings.discordRpc) setIdle();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const next = downloadStore.queue.slice();
|
|
||||||
downloadStore.detectTransitions(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
|
||||||
(window as any).__mokuShowSplash = () => { devSplash = true; };
|
|
||||||
|
|
||||||
applyZoom();
|
|
||||||
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
|
|
||||||
const unlistenResize = await win.onResized(async () => {
|
|
||||||
store.isFullscreen = await win.isFullscreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlistenScale = await win.onScaleChanged(async () => {
|
|
||||||
applyZoom();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
|
||||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
|
||||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
|
||||||
else console.warn("Could not start server:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startProbe();
|
|
||||||
|
|
||||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
|
||||||
"download-progress",
|
|
||||||
e => setActiveDownloads(e.payload),
|
|
||||||
);
|
|
||||||
|
|
||||||
await downloadStore.poll();
|
|
||||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopProbe();
|
|
||||||
clearInterval(dlInterval);
|
|
||||||
unlistenResize();
|
|
||||||
unlistenScale();
|
|
||||||
unlistenDownload();
|
|
||||||
destroyRpc();
|
|
||||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
|
||||||
delete (window as any).__mokuShowSplash;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if devSplash}
|
|
||||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
|
||||||
|
|
||||||
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
|
||||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
|
||||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
|
||||||
showCards={store.settings.splashCards ?? true}
|
|
||||||
onReady={() => { appReady = true; }}
|
|
||||||
onRetry={retryBoot}
|
|
||||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
|
||||||
|
|
||||||
{:else if boot.unsupportedMode || boot.loginRequired}
|
|
||||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
|
||||||
<AuthGate onReady={() => { appReady = true; }} />
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
{#if idle && !store.activeChapter}
|
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { idle = false; }} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div id="app-shell" class="root">
|
|
||||||
{#if !store.activeChapter}<TitleBar />{/if}
|
|
||||||
<div class="content">
|
|
||||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
|
|
||||||
{#if themeEditorOpen}
|
|
||||||
<ThemeEditor bind:editingId={themeEditorEditId} onClose={closeThemeEditor} />
|
|
||||||
{/if}
|
|
||||||
<MangaPreview />
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
|
||||||
</style>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
import { fetchAuthenticated } from "../core/auth";
|
|
||||||
|
|
||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
|
||||||
|
|
||||||
function getServerUrl(): string {
|
|
||||||
const url = store.settings.serverUrl;
|
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function plainThumbUrl(path: string): string {
|
|
||||||
if (!path) return "";
|
|
||||||
if (path.startsWith("http")) return path;
|
|
||||||
return `${getServerUrl()}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const thumbUrl = plainThumbUrl;
|
|
||||||
|
|
||||||
interface GQLResponse<T> {
|
|
||||||
data: T;
|
|
||||||
errors?: { message: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
|
||||||
const timer = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener("abort", () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
retries = 3,
|
|
||||||
delayMs = 300,
|
|
||||||
): Promise<Response> {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
try {
|
|
||||||
const res = await fetchAuthenticated(url, init, signal);
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
return res;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.authRequired) throw e;
|
|
||||||
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (i === retries - 1) throw e;
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("unreachable");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gql<T>(
|
|
||||||
query: string,
|
|
||||||
variables?: Record<string, unknown>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetchWithRetry(
|
|
||||||
`${getServerUrl()}/api/graphql`,
|
|
||||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
|
||||||
const json: GQLResponse<T> = await res.json();
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
||||||
return json.data;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export * from "./client";
|
|
||||||
export * from "./queries/manga";
|
|
||||||
export * from "./queries/chapters";
|
|
||||||
export * from "./queries/downloads";
|
|
||||||
export * from "./queries/extensions";
|
|
||||||
export * from "./queries/tracking";
|
|
||||||
export * from "./mutations/manga";
|
|
||||||
export * from "./mutations/chapters";
|
|
||||||
export * from "./mutations/downloads";
|
|
||||||
export * from "./mutations/extensions";
|
|
||||||
export * from "./mutations/tracking";
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
export const FETCH_CHAPTERS = `
|
|
||||||
mutation FetchChapters($mangaId: Int!) {
|
|
||||||
fetchChapters(input: { mangaId: $mangaId }) {
|
|
||||||
chapters {
|
|
||||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
|
||||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const FETCH_CHAPTER_PAGES = `
|
|
||||||
mutation FetchChapterPages($chapterId: Int!) {
|
|
||||||
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MARK_CHAPTER_READ = `
|
|
||||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
|
||||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
|
||||||
chapter { id isRead }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MARK_CHAPTERS_READ = `
|
|
||||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
|
||||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
|
||||||
chapters { id isRead }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_CHAPTERS_PROGRESS = `
|
|
||||||
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
|
|
||||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
|
|
||||||
chapters { id isRead isBookmarked lastPageRead }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_DOWNLOADED_CHAPTERS = `
|
|
||||||
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
|
||||||
deleteDownloadedChapters(input: { ids: $ids }) {
|
|
||||||
chapters { id isDownloaded }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
export const FETCH_EXTENSIONS = `
|
|
||||||
mutation FetchExtensions {
|
|
||||||
fetchExtensions(input: {}) {
|
|
||||||
extensions {
|
|
||||||
apkName pkgName name lang versionName
|
|
||||||
isInstalled isObsolete hasUpdate iconUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_EXTENSION = `
|
|
||||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
|
||||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
|
||||||
extension { apkName pkgName name isInstalled hasUpdate }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
|
||||||
mutation InstallExternalExtension($url: String!) {
|
|
||||||
installExternalExtension(input: { extensionUrl: $url }) {
|
|
||||||
extension { apkName pkgName name isInstalled }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_EXTENSION_REPOS = `
|
|
||||||
mutation SetExtensionRepos($repos: [String!]!) {
|
|
||||||
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
|
||||||
settings { extensionRepos }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SERVER_AUTH = `
|
|
||||||
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
|
||||||
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
|
||||||
settings { authMode authUsername }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_SOCKS_PROXY = `
|
|
||||||
mutation SetSocksProxy(
|
|
||||||
$socksProxyEnabled: Boolean!
|
|
||||||
$socksProxyHost: String!
|
|
||||||
$socksProxyPort: String!
|
|
||||||
$socksProxyVersion: Int!
|
|
||||||
$socksProxyUsername: String!
|
|
||||||
$socksProxyPassword: String!
|
|
||||||
) {
|
|
||||||
setSettings(input: { settings: {
|
|
||||||
socksProxyEnabled: $socksProxyEnabled
|
|
||||||
socksProxyHost: $socksProxyHost
|
|
||||||
socksProxyPort: $socksProxyPort
|
|
||||||
socksProxyVersion: $socksProxyVersion
|
|
||||||
socksProxyUsername: $socksProxyUsername
|
|
||||||
socksProxyPassword: $socksProxyPassword
|
|
||||||
}}) {
|
|
||||||
settings { socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SET_FLARESOLVERR = `
|
|
||||||
mutation SetFlareSolverr(
|
|
||||||
$flareSolverrEnabled: Boolean!
|
|
||||||
$flareSolverrUrl: String!
|
|
||||||
$flareSolverrTimeout: Int!
|
|
||||||
$flareSolverrSessionName: String!
|
|
||||||
$flareSolverrSessionTtl: Int!
|
|
||||||
$flareSolverrAsResponseFallback: Boolean!
|
|
||||||
) {
|
|
||||||
setSettings(input: { settings: {
|
|
||||||
flareSolverrEnabled: $flareSolverrEnabled
|
|
||||||
flareSolverrUrl: $flareSolverrUrl
|
|
||||||
flareSolverrTimeout: $flareSolverrTimeout
|
|
||||||
flareSolverrSessionName: $flareSolverrSessionName
|
|
||||||
flareSolverrSessionTtl: $flareSolverrSessionTtl
|
|
||||||
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
|
|
||||||
}}) {
|
|
||||||
settings {
|
|
||||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
|
||||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from "./manga";
|
|
||||||
export * from "./chapters";
|
|
||||||
export * from "./downloads";
|
|
||||||
export * from "./extensions";
|
|
||||||
export * from "./tracking";
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
export const FETCH_MANGA = `
|
|
||||||
mutation FetchManga($id: Int!) {
|
|
||||||
fetchManga(input: { id: $id }) {
|
|
||||||
manga {
|
|
||||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
|
||||||
source { id name displayName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_MANGA = `
|
|
||||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
|
||||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
|
||||||
manga { id inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_MANGAS = `
|
|
||||||
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
|
||||||
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
|
||||||
mangas { id inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_MANGA_CATEGORIES = `
|
|
||||||
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
|
||||||
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
|
||||||
manga { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CREATE_CATEGORY = `
|
|
||||||
mutation CreateCategory($name: String!) {
|
|
||||||
createCategory(input: { name: $name }) {
|
|
||||||
category { id name order default includeInUpdate includeInDownload }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_CATEGORY = `
|
|
||||||
mutation UpdateCategory($id: Int!, $name: String) {
|
|
||||||
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
|
||||||
category { id name order }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DELETE_CATEGORY = `
|
|
||||||
mutation DeleteCategory($id: Int!) {
|
|
||||||
deleteCategory(input: { categoryId: $id }) {
|
|
||||||
category { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_CATEGORY_ORDER = `
|
|
||||||
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
|
||||||
updateCategoryOrder(input: { id: $id, position: $position }) {
|
|
||||||
categories { id name order default includeInUpdate includeInDownload }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_LIBRARY = `
|
|
||||||
mutation UpdateLibrary {
|
|
||||||
updateLibrary(input: {}) {
|
|
||||||
updateStatus {
|
|
||||||
jobsInfo { isRunning finishedJobs totalJobs }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CREATE_BACKUP = `
|
|
||||||
mutation CreateBackup {
|
|
||||||
createBackup(input: {}) { url }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const RESTORE_BACKUP = `
|
|
||||||
mutation RestoreBackup($backup: Upload!) {
|
|
||||||
restoreBackup(input: { backup: $backup }) {
|
|
||||||
id
|
|
||||||
status { mangaProgress state totalManga }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
# Mutations
|
|
||||||
|
|
||||||
## Manga (`mutations/manga.ts`)
|
|
||||||
|
|
||||||
### `FETCH_MANGA`
|
|
||||||
Fetches and refreshes manga metadata from its source.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_MANGA`
|
|
||||||
Updates a single manga's library membership.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Manga ID |
|
|
||||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_MANGAS`
|
|
||||||
Bulk-updates library membership for multiple manga.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Manga IDs |
|
|
||||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_MANGA_CATEGORIES`
|
|
||||||
Adds or removes a manga from categories.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
| `addTo` | `[Int!]!` | Category IDs to add to |
|
|
||||||
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CREATE_CATEGORY`
|
|
||||||
Creates a new manga category.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `name` | `String!` | Category name |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CATEGORY`
|
|
||||||
Updates a category's name.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
| `name` | `String` | New name |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DELETE_CATEGORY`
|
|
||||||
Deletes a category by ID.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CATEGORY_ORDER`
|
|
||||||
Moves a category to a new position.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Category ID |
|
|
||||||
| `position` | `Int!` | New position index |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_LIBRARY`
|
|
||||||
Triggers a library-wide metadata refresh and returns job status.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CREATE_BACKUP`
|
|
||||||
Creates a backup and returns its download URL.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `RESTORE_BACKUP`
|
|
||||||
Restores a backup from an uploaded file and returns restore job status.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `backup` | `Upload!` | Backup file |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chapters (`mutations/chapters.ts`)
|
|
||||||
|
|
||||||
### `FETCH_CHAPTERS`
|
|
||||||
Fetches/refreshes the chapter list for a manga from its source.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `FETCH_CHAPTER_PAGES`
|
|
||||||
Fetches the page URLs for a specific chapter.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `MARK_CHAPTER_READ`
|
|
||||||
Marks a single chapter as read or unread.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Chapter ID |
|
|
||||||
| `isRead` | `Boolean!` | Read state |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `MARK_CHAPTERS_READ`
|
|
||||||
Bulk-marks multiple chapters as read or unread.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
| `isRead` | `Boolean!` | Read state |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_CHAPTERS_PROGRESS`
|
|
||||||
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
| `isRead` | `Boolean` | Read state |
|
|
||||||
| `isBookmarked` | `Boolean` | Bookmark state |
|
|
||||||
| `lastPageRead` | `Int` | Last page index read |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DELETE_DOWNLOADED_CHAPTERS`
|
|
||||||
Deletes downloaded chapter files for the given chapter IDs.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `ids` | `[Int!]!` | Chapter IDs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Downloads (`mutations/downloads.ts`)
|
|
||||||
|
|
||||||
### `ENQUEUE_DOWNLOAD`
|
|
||||||
Adds a single chapter to the download queue.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
|
||||||
Adds multiple chapters to the download queue.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `DEQUEUE_DOWNLOAD`
|
|
||||||
Removes a chapter from the download queue.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `chapterId` | `Int!` | Chapter ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `START_DOWNLOADER`
|
|
||||||
Starts the downloader and returns the current queue state.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `STOP_DOWNLOADER`
|
|
||||||
Stops the downloader and returns the current queue state.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `CLEAR_DOWNLOADER`
|
|
||||||
Clears all items from the download queue.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `FETCH_SOURCE_MANGA`
|
|
||||||
Fetches manga from a source (browse/search), with pagination and optional filters.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `source` | `LongString!` | Source ID |
|
|
||||||
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
|
||||||
| `page` | `Int!` | Page number |
|
|
||||||
| `query` | `String` | Search query |
|
|
||||||
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_DOWNLOADS_PATH`
|
|
||||||
Sets the downloads directory path in settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `path` | `String!` | Filesystem path |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_LOCAL_SOURCE_PATH`
|
|
||||||
Sets the local source directory path in settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `path` | `String!` | Filesystem path |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extensions (`mutations/extensions.ts`)
|
|
||||||
|
|
||||||
### `FETCH_EXTENSIONS`
|
|
||||||
Fetches the latest extension list from configured repos.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_EXTENSION`
|
|
||||||
Installs, uninstalls, or updates an extension.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `String!` | Extension package name |
|
|
||||||
| `install` | `Boolean` | Install the extension |
|
|
||||||
| `uninstall` | `Boolean` | Uninstall the extension |
|
|
||||||
| `update` | `Boolean` | Update the extension |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `INSTALL_EXTERNAL_EXTENSION`
|
|
||||||
Installs an extension from an external APK URL.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `url` | `String!` | APK download URL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_EXTENSION_REPOS`
|
|
||||||
Sets the list of extension repository URLs.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `repos` | `[String!]!` | Repository URLs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_SERVER_AUTH`
|
|
||||||
Configures server authentication mode and credentials.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `authMode` | `AuthMode!` | Auth mode |
|
|
||||||
| `authUsername` | `String!` | Username |
|
|
||||||
| `authPassword` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_SOCKS_PROXY`
|
|
||||||
Configures SOCKS proxy settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
|
||||||
| `socksProxyHost` | `String!` | Proxy host |
|
|
||||||
| `socksProxyPort` | `String!` | Proxy port |
|
|
||||||
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
|
||||||
| `socksProxyUsername` | `String!` | Proxy username |
|
|
||||||
| `socksProxyPassword` | `String!` | Proxy password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SET_FLARESOLVERR`
|
|
||||||
Configures FlareSolverr integration settings.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
|
||||||
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
|
||||||
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
|
||||||
| `flareSolverrSessionName` | `String!` | Session name |
|
|
||||||
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
|
||||||
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking (`mutations/tracking.ts`)
|
|
||||||
|
|
||||||
### `BIND_TRACK`
|
|
||||||
Binds a manga to a remote tracker entry.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UPDATE_TRACK`
|
|
||||||
Updates tracking progress, status, score, and dates for a track record.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `recordId` | `Int!` | Track record ID |
|
|
||||||
| `status` | `Int` | Reading status |
|
|
||||||
| `lastChapterRead` | `Float` | Last chapter read |
|
|
||||||
| `scoreString` | `String` | Score in tracker's format |
|
|
||||||
| `startDate` | `LongString` | Start date |
|
|
||||||
| `finishDate` | `LongString` | Finish date |
|
|
||||||
| `private` | `Boolean` | Mark as private |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `UNBIND_TRACK`
|
|
||||||
Unbinds a manga from a tracker record.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `recordId` | `Int!` | Track record ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `FETCH_TRACK`
|
|
||||||
Refreshes a track record from the remote tracker.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `recordId` | `Int!` | Track record ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_TRACKER_OAUTH`
|
|
||||||
Initiates OAuth login for a tracker using a callback URL.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `callbackUrl` | `String!` | OAuth callback URL |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_TRACKER_CREDENTIALS`
|
|
||||||
Logs into a tracker using username and password.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `username` | `String!` | Username |
|
|
||||||
| `password` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGOUT_TRACKER`
|
|
||||||
Logs out of a tracker.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LOGIN_USER`
|
|
||||||
Authenticates a user and returns access and refresh tokens.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `username` | `String!` | Username |
|
|
||||||
| `password` | `String!` | Password |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `REFRESH_TOKEN`
|
|
||||||
Refreshes the current access token.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
const TRACK_RECORD_FRAGMENT = `
|
|
||||||
id trackerId remoteId title status score displayScore
|
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const BIND_TRACK = `
|
|
||||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
|
||||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
|
||||||
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UPDATE_TRACK = `
|
|
||||||
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
|
||||||
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
|
||||||
trackRecord {
|
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UNBIND_TRACK = `
|
|
||||||
mutation UnbindTrack($recordId: Int!) {
|
|
||||||
unbindTrack(input: { recordId: $recordId }) {
|
|
||||||
trackRecord { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const FETCH_TRACK = `
|
|
||||||
mutation FetchTrack($recordId: Int!) {
|
|
||||||
fetchTrack(input: { recordId: $recordId }) {
|
|
||||||
trackRecord {
|
|
||||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_TRACKER_OAUTH = `
|
|
||||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
|
||||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
|
||||||
isLoggedIn
|
|
||||||
tracker { id name isLoggedIn authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
|
||||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
|
||||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
|
||||||
isLoggedIn
|
|
||||||
tracker { id name isLoggedIn authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGOUT_TRACKER = `
|
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
|
||||||
logoutTracker(input: { trackerId: $trackerId }) {
|
|
||||||
tracker { id name isLoggedIn authUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LOGIN_USER = `
|
|
||||||
mutation Login($username: String!, $password: String!) {
|
|
||||||
login(input: { username: $username, password: $password }) {
|
|
||||||
accessToken refreshToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const REFRESH_TOKEN = `
|
|
||||||
mutation RefreshToken {
|
|
||||||
refreshToken { accessToken }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export const GET_RECENTLY_UPDATED = `
|
|
||||||
query GetRecentlyUpdated {
|
|
||||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
|
||||||
nodes {
|
|
||||||
mangaId
|
|
||||||
fetchedAt
|
|
||||||
manga { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_CHAPTERS = `
|
|
||||||
query GetChapters($mangaId: Int!) {
|
|
||||||
chapters(condition: { mangaId: $mangaId }) {
|
|
||||||
nodes {
|
|
||||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
|
||||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const GET_DOWNLOAD_STATUS = `
|
|
||||||
query GetDownloadStatus {
|
|
||||||
downloadStatus {
|
|
||||||
state
|
|
||||||
queue {
|
|
||||||
progress state
|
|
||||||
chapter {
|
|
||||||
id name pageCount mangaId
|
|
||||||
manga { id title thumbnailUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export const GET_LOCAL_MANGA = `
|
|
||||||
query GetLocalManga {
|
|
||||||
mangas(condition: { sourceId: "0" }) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_EXTENSIONS = `
|
|
||||||
query GetExtensions {
|
|
||||||
extensions {
|
|
||||||
nodes {
|
|
||||||
apkName pkgName name lang versionName
|
|
||||||
isInstalled isObsolete hasUpdate iconUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SOURCES = `
|
|
||||||
query GetSources {
|
|
||||||
sources {
|
|
||||||
nodes { id name lang displayName iconUrl isNsfw }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SETTINGS = `
|
|
||||||
query GetSettings {
|
|
||||||
settings { extensionRepos }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_SERVER_SECURITY = `
|
|
||||||
query GetServerSecurity {
|
|
||||||
settings {
|
|
||||||
authMode authUsername
|
|
||||||
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
|
||||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
|
||||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from "./manga";
|
|
||||||
export * from "./chapters";
|
|
||||||
export * from "./downloads";
|
|
||||||
export * from "./extensions";
|
|
||||||
export * from "./tracking";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
export const GET_LIBRARY = `
|
|
||||||
query GetLibrary {
|
|
||||||
mangas(condition: { inLibrary: true }) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary downloadCount unreadCount
|
|
||||||
description status author artist genre
|
|
||||||
source { id name displayName }
|
|
||||||
chapters { totalCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_ALL_MANGA = `
|
|
||||||
query GetAllManga {
|
|
||||||
mangas {
|
|
||||||
nodes { id title thumbnailUrl inLibrary downloadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_MANGA = `
|
|
||||||
query GetManga($id: Int!) {
|
|
||||||
manga(id: $id) {
|
|
||||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
|
||||||
source { id name displayName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_CATEGORIES = `
|
|
||||||
query GetCategories {
|
|
||||||
categories {
|
|
||||||
nodes {
|
|
||||||
id name order default includeInUpdate includeInDownload
|
|
||||||
mangas {
|
|
||||||
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
|
||||||
query GetDownloadedChaptersPages {
|
|
||||||
chapters(condition: { isDownloaded: true }) {
|
|
||||||
nodes { pageCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_DOWNLOADS_PATH = `
|
|
||||||
query GetDownloadsPath {
|
|
||||||
settings { downloadsPath localSourcePath }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LIBRARY_UPDATE_STATUS = `
|
|
||||||
query LibraryUpdateStatus {
|
|
||||||
libraryUpdateStatus {
|
|
||||||
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
|
|
||||||
mangaUpdates {
|
|
||||||
status
|
|
||||||
manga { id title thumbnailUrl unreadCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_RESTORE_STATUS = `
|
|
||||||
query GetRestoreStatus($id: String!) {
|
|
||||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const VALIDATE_BACKUP = `
|
|
||||||
query ValidateBackup($backup: Upload!) {
|
|
||||||
validateBackup(input: { backup: $backup }) {
|
|
||||||
missingSources { id name }
|
|
||||||
missingTrackers { name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
|
||||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary genre status
|
|
||||||
source { id displayName }
|
|
||||||
}
|
|
||||||
pageInfo { hasNextPage }
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# Queries
|
|
||||||
|
|
||||||
## Manga (`queries/manga.ts`)
|
|
||||||
|
|
||||||
### `GET_LIBRARY`
|
|
||||||
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_ALL_MANGA`
|
|
||||||
Fetches all manga (library and non-library) with minimal fields.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_MANGA`
|
|
||||||
Fetches a single manga by ID with full metadata and source info.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_CATEGORIES`
|
|
||||||
Fetches all categories with their order, settings, and the manga assigned to each.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
|
||||||
Fetches page counts for all downloaded chapters.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_DOWNLOADS_PATH`
|
|
||||||
Fetches the configured downloads path and local source path from settings.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `LIBRARY_UPDATE_STATUS`
|
|
||||||
Fetches the current library update job status, including progress and any manga with new chapters.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_RESTORE_STATUS`
|
|
||||||
Fetches the status of a backup restore operation by its job ID.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `id` | `String!` | Restore job ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `VALIDATE_BACKUP`
|
|
||||||
Validates a backup file and returns any missing sources or trackers.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `backup` | `Upload!` | Backup file |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chapters (`queries/chapters.ts`)
|
|
||||||
|
|
||||||
### `GET_CHAPTERS`
|
|
||||||
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Downloads (`queries/downloads.ts`)
|
|
||||||
|
|
||||||
### `GET_DOWNLOAD_STATUS`
|
|
||||||
Fetches the current downloader state and full queue with chapter and manga info.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extensions (`queries/extensions.ts`)
|
|
||||||
|
|
||||||
### `GET_EXTENSIONS`
|
|
||||||
Fetches all extensions with install status, update availability, and metadata.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_SOURCES`
|
|
||||||
Fetches all available sources with language and NSFW flags.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_SETTINGS`
|
|
||||||
Fetches extension repository settings.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_SERVER_SECURITY`
|
|
||||||
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking (`queries/tracking.ts`)
|
|
||||||
|
|
||||||
### `GET_TRACKERS`
|
|
||||||
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_MANGA_TRACK_RECORDS`
|
|
||||||
Fetches all tracking records for a specific manga across all trackers.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `mangaId` | `Int!` | Manga ID |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `SEARCH_TRACKER`
|
|
||||||
Searches a tracker for manga by query string.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
| `query` | `String!` | Search query |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_ALL_TRACKER_RECORDS`
|
|
||||||
Fetches all trackers and their full track records, including associated manga info.
|
|
||||||
|
|
||||||
**Variables:** none
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET_TRACKER_RECORDS`
|
|
||||||
Fetches track records for a specific tracker.
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
| Name | Type | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `trackerId` | `Int!` | Tracker ID |
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
export const GET_TRACKERS = `
|
|
||||||
query GetTrackers {
|
|
||||||
trackers {
|
|
||||||
nodes {
|
|
||||||
id name icon isLoggedIn authUrl supportsPrivateTracking scores
|
|
||||||
statuses { value name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_MANGA_TRACK_RECORDS = `
|
|
||||||
query GetMangaTrackRecords($mangaId: Int!) {
|
|
||||||
manga(id: $mangaId) {
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id trackerId remoteId title status score displayScore
|
|
||||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SEARCH_TRACKER = `
|
|
||||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
|
||||||
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
|
||||||
trackSearches {
|
|
||||||
id trackerId remoteId title coverUrl summary
|
|
||||||
publishingStatus publishingType startDate totalChapters trackingUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_ALL_TRACKER_RECORDS = `
|
|
||||||
query GetAllTrackerRecords {
|
|
||||||
trackers {
|
|
||||||
nodes {
|
|
||||||
id name icon isLoggedIn scores
|
|
||||||
statuses { value name }
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id trackerId title status displayScore lastChapterRead
|
|
||||||
totalChapters remoteUrl private
|
|
||||||
manga { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const GET_TRACKER_RECORDS = `
|
|
||||||
query GetTrackerRecords($trackerId: Int!) {
|
|
||||||
trackers(condition: { id: $trackerId }) {
|
|
||||||
nodes {
|
|
||||||
id name
|
|
||||||
statuses { value name }
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id title status displayScore lastChapterRead totalChapters remoteUrl
|
|
||||||
manga { id title thumbnailUrl }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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>
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./selectPortal";
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@attach selectPortal(triggerEl)}
|
|
||||||
*
|
|
||||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
|
||||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
|
||||||
*
|
|
||||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
|
||||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
|
||||||
*/
|
|
||||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
|
||||||
return (menuEl: HTMLElement) => {
|
|
||||||
// Position & move to body
|
|
||||||
function position() {
|
|
||||||
const r = triggerEl.getBoundingClientRect();
|
|
||||||
menuEl.style.position = "fixed";
|
|
||||||
menuEl.style.top = `${r.bottom + 4}px`;
|
|
||||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
|
||||||
// clamp to viewport left edge
|
|
||||||
const left = parseFloat(menuEl.style.left);
|
|
||||||
if (left < 8) menuEl.style.left = "8px";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(menuEl);
|
|
||||||
triggerEl.__selectMenuEl = menuEl;
|
|
||||||
position();
|
|
||||||
|
|
||||||
// Reposition on scroll / resize while open
|
|
||||||
window.addEventListener("scroll", position, true);
|
|
||||||
window.addEventListener("resize", position);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", position, true);
|
|
||||||
window.removeEventListener("resize", position);
|
|
||||||
triggerEl.__selectMenuEl = null;
|
|
||||||
menuEl.remove();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { Manga, Source } from "@types";
|
|
||||||
import type { Settings } from "@types";
|
|
||||||
import { shouldHideSource } from "@core/util";
|
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name, preferring `preferredLang` when multiple
|
|
||||||
* sources share a name. The local source (id "0") is always excluded.
|
|
||||||
*
|
|
||||||
* When `applyHide` is true, sources that fail the NSFW/block check are
|
|
||||||
* also removed — used in fan-out and cache-build paths where only
|
|
||||||
* user-visible sources should be queried.
|
|
||||||
*/
|
|
||||||
export function dedupeSourcesByLang(
|
|
||||||
sources: Source[],
|
|
||||||
preferredLang: string,
|
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
|
||||||
applyHide = false,
|
|
||||||
): Source[] {
|
|
||||||
const map = new Map<string, Source>();
|
|
||||||
for (const s of sources) {
|
|
||||||
if (s.id === "0") continue;
|
|
||||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
|
||||||
const existing = map.get(s.name);
|
|
||||||
if (!existing) { map.set(s.name, s); continue; }
|
|
||||||
const existingPref = existing.lang === preferredLang;
|
|
||||||
const newPref = s.lang === preferredLang;
|
|
||||||
if (newPref && !existingPref) map.set(s.name, s);
|
|
||||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Manga predicate filters ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic predicate pipeline — composes multiple boolean predicates into one.
|
|
||||||
* All predicates must return true for an item to pass.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const keep = buildFilter<Manga>(
|
|
||||||
* m => !shouldHideNsfw(m, settings),
|
|
||||||
* m => m.inLibrary,
|
|
||||||
* );
|
|
||||||
* const filtered = items.filter(keep);
|
|
||||||
*/
|
|
||||||
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
|
||||||
return (item) => predicates.every((p) => p(item));
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './sort';
|
|
||||||
export * from './filter';
|
|
||||||
export * from './paginate';
|
|
||||||
export * from './search';
|
|
||||||
export * from './queue';
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export interface AsyncQueue<T> {
|
|
||||||
enqueue(item: T): void;
|
|
||||||
drain(): void;
|
|
||||||
clear(): void;
|
|
||||||
size(): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAsyncQueue<T>(
|
|
||||||
worker: (item: T) => Promise<void>,
|
|
||||||
concurrency = 1,
|
|
||||||
): AsyncQueue<T> {
|
|
||||||
const queue: T[] = [];
|
|
||||||
let active = 0;
|
|
||||||
|
|
||||||
function next() {
|
|
||||||
while (active < concurrency && queue.length > 0) {
|
|
||||||
const item = queue.shift()!;
|
|
||||||
active++;
|
|
||||||
worker(item).finally(() => { active--; next(); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
enqueue(item) { queue.push(item); next(); },
|
|
||||||
drain() { next(); },
|
|
||||||
clear() { queue.length = 0; },
|
|
||||||
size() { return queue.length; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export interface SearchResult<T> {
|
|
||||||
item: T;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchItems<T>(
|
|
||||||
items: T[],
|
|
||||||
query: string,
|
|
||||||
getField: (item: T) => string,
|
|
||||||
): T[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return items;
|
|
||||||
return items.filter(item => getField(item).toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchWithScore<T>(
|
|
||||||
items: T[],
|
|
||||||
query: string,
|
|
||||||
getField: (item: T) => string,
|
|
||||||
): SearchResult<T>[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return items.map(item => ({ item, score: 0 }));
|
|
||||||
|
|
||||||
return items
|
|
||||||
.map(item => {
|
|
||||||
const field = getField(item).toLowerCase();
|
|
||||||
if (!field.includes(q)) return null;
|
|
||||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
|
||||||
return { item, score };
|
|
||||||
})
|
|
||||||
.filter((r): r is SearchResult<T> => r !== null)
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Runs an async task over every item in `items`, with at most `concurrency`
|
|
||||||
* tasks in-flight at once. Respects the provided AbortSignal — each worker
|
|
||||||
* exits early if the signal fires. Errors thrown by individual tasks are
|
|
||||||
* swallowed so one failure does not cancel the whole batch.
|
|
||||||
*/
|
|
||||||
export async function runConcurrent<T>(
|
|
||||||
items: T[],
|
|
||||||
fn: (item: T) => Promise<void>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
concurrency = 6,
|
|
||||||
): Promise<void> {
|
|
||||||
let i = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
const item = items[i++];
|
|
||||||
await fn(item).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates in-flight async calls by key.
|
|
||||||
*
|
|
||||||
* Two call signatures are supported:
|
|
||||||
*
|
|
||||||
* 1. Direct call — supply a key and a zero-arg factory each time:
|
|
||||||
* dedupeRequest("my-key", () => fetchSomething())
|
|
||||||
* If a request with that key is already pending, the existing Promise is
|
|
||||||
* returned and the factory is not called again.
|
|
||||||
*
|
|
||||||
* 2. Curried wrapper — supply a key-based fetcher once, get back a
|
|
||||||
* single-arg function you can call repeatedly:
|
|
||||||
* const get = dedupeRequest((key) => fetchSomething(key))
|
|
||||||
* get("my-key")
|
|
||||||
*/
|
|
||||||
const _inflight = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
|
||||||
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
|
||||||
export function dedupeRequest<T>(
|
|
||||||
keyOrFn: string | ((key: string) => Promise<T>),
|
|
||||||
factory?: () => Promise<T>,
|
|
||||||
): Promise<T> | ((key: string) => Promise<T>) {
|
|
||||||
// Curried wrapper form
|
|
||||||
if (typeof keyOrFn === 'function') {
|
|
||||||
const fn = keyOrFn;
|
|
||||||
return (key: string) => dedupeRequest(key, () => fn(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct call form
|
|
||||||
const key = keyOrFn;
|
|
||||||
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>;
|
|
||||||
const p = factory!().finally(() => _inflight.delete(key));
|
|
||||||
_inflight.set(key, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export interface PaginatedQuery<T> {
|
|
||||||
fetchPage(page: number): Promise<T[]>;
|
|
||||||
reset(): void;
|
|
||||||
hasMore(): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedQueryConfig<T> {
|
|
||||||
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPaginatedQuery<T>(
|
|
||||||
config: PaginatedQueryConfig<T>,
|
|
||||||
): PaginatedQuery<T> {
|
|
||||||
let _hasMore = true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async fetchPage(page) {
|
|
||||||
const { items, hasNextPage } = await config.fetcher(page);
|
|
||||||
_hasMore = hasNextPage;
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
reset() { _hasMore = true; },
|
|
||||||
hasMore() { return _hasMore; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './fetchWithRetry';
|
|
||||||
export * from './batchRequests';
|
|
||||||
export * from './createPaginatedQuery';
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { store, updateSettings } from "@store/state.svelte";
|
|
||||||
|
|
||||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
|
||||||
|
|
||||||
export const authSession = {
|
|
||||||
clearTokens() {},
|
|
||||||
hasSession(): boolean { return true; },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServerBase(): string {
|
|
||||||
const url = store.settings.serverUrl;
|
|
||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
|
||||||
}
|
|
||||||
|
|
||||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
|
||||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
return fetch(url, {
|
|
||||||
...init, signal, credentials: "omit",
|
|
||||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
|
||||||
method: "POST", credentials: "omit",
|
|
||||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
|
||||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
updateSettings({ serverAuthPass: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
|
||||||
const base = getServerBase();
|
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
|
||||||
const s = store.settings;
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = s.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = s.serverAuthPass?.trim() ?? "";
|
|
||||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
|
||||||
}
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST", credentials: "omit", headers,
|
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok) return "ok";
|
|
||||||
if (res.status === 401) {
|
|
||||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
|
||||||
if (/basic/i.test(wwwAuth)) {
|
|
||||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
|
||||||
return "auth_required";
|
|
||||||
}
|
|
||||||
if (/bearer/i.test(wwwAuth)) {
|
|
||||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
|
||||||
} else if (mode === "NONE") {
|
|
||||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
|
||||||
}
|
|
||||||
return "unsupported_mode";
|
|
||||||
}
|
|
||||||
return "unreachable";
|
|
||||||
} catch { return "unreachable"; }
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './memoryCache';
|
|
||||||
export * from './pageCache';
|
|
||||||
export * from './imageCache';
|
|
||||||
export * from './queryCache';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from "./keybindEngine";
|
|
||||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from "./defaultBinds";
|
|
||||||
export type { Keybinds } from "./defaultBinds";
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
|
|
||||||
let themeStyleEl: HTMLStyleElement | null = null;
|
|
||||||
|
|
||||||
export function applyTheme() {
|
|
||||||
const themeId = store.settings.theme ?? "dark";
|
|
||||||
const isCustom = themeId.startsWith("custom:");
|
|
||||||
|
|
||||||
if (!isCustom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", themeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const custom = store.settings.customThemes?.find(t => t.id === themeId);
|
|
||||||
if (!custom) {
|
|
||||||
themeStyleEl?.remove();
|
|
||||||
themeStyleEl = null;
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vars = Object.entries(custom.tokens)
|
|
||||||
.map(([k, v]) => ` --${k}: ${v};`)
|
|
||||||
.join("\n");
|
|
||||||
const css = `[data-theme="custom"] {\n${vars}\n}`;
|
|
||||||
|
|
||||||
if (!themeStyleEl) {
|
|
||||||
themeStyleEl = document.createElement("style");
|
|
||||||
themeStyleEl.id = "moku-custom-theme";
|
|
||||||
document.head.appendChild(themeStyleEl);
|
|
||||||
}
|
|
||||||
themeStyleEl.textContent = css;
|
|
||||||
document.documentElement.setAttribute("data-theme", "custom");
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { store } from "@store/state.svelte";
|
|
||||||
|
|
||||||
const IDLE_EVENTS = ["mousemove", "mousedown", "keydown", "touchstart", "wheel"] as const;
|
|
||||||
|
|
||||||
export function mountIdleDetection(onIdle: () => void, onActive: () => void): () => void {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (ms === 0) return;
|
|
||||||
timer = setTimeout(onIdle, ms);
|
|
||||||
onActive();
|
|
||||||
}
|
|
||||||
|
|
||||||
IDLE_EVENTS.forEach(e => window.addEventListener(e, reset, { passive: true }));
|
|
||||||
reset();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
IDLE_EVENTS.forEach(e => window.removeEventListener(e, reset));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './idle';
|
|
||||||
export * from './zoom';
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { addToast } from "@store/state.svelte";
|
|
||||||
|
|
||||||
function parse(tag: string): number[] {
|
|
||||||
return tag.replace(/^v/, "").split(".").map(Number);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compare(a: number[], b: number[]): number {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkForUpdateSilently(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const [currentVersion, releases] = await Promise.all([
|
|
||||||
getVersion(),
|
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
|
||||||
if (!valid.length) return;
|
|
||||||
|
|
||||||
const latestTag = valid
|
|
||||||
.map(r => r.tag_name)
|
|
||||||
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
|
||||||
.replace(/^v/, "");
|
|
||||||
|
|
||||||
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
|
|
||||||
addToast({
|
|
||||||
kind: "info",
|
|
||||||
title: `Update available — v${latestTag}`,
|
|
||||||
body: "Open Settings → About to install.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeUp {
|
|
||||||
from { opacity: 0; transform: translateY(5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeDown {
|
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { opacity: 0; transform: scale(0.97); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
from { background-position: -200% 0; }
|
|
||||||
to { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.anim-fade-in { animation: fadeIn 0.14s ease both; }
|
|
||||||
.anim-fade-up { animation: fadeUp 0.18s ease both; }
|
|
||||||
.anim-fade-down { animation: fadeDown 0.18s ease both; }
|
|
||||||
.anim-scale-in { animation: scaleIn 0.14s ease both; }
|
|
||||||
.anim-pulse { animation: pulse 1.6s ease infinite; }
|
|
||||||
.anim-spin { animation: spin 0.7s linear infinite; }
|
|
||||||
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.4s ease infinite;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@import "./reset.css";
|
|
||||||
@import "./animations.css";
|
|
||||||
@import "./scrollbars.css";
|
|
||||||
@import "./typography.css";
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-void);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, select {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol { list-style: none; }
|
|
||||||
|
|
||||||
img, svg { display: block; max-width: 100%; }
|
|
||||||
|
|
||||||
p { margin: 0; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: transparent transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
||||||
*::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
|
||||||
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[data-theme="high-contrast"] {
|
|
||||||
--bg-void: #000000;
|
|
||||||
--bg-base: #080808;
|
|
||||||
--bg-surface: #0d0d0d;
|
|
||||||
--bg-raised: #111111;
|
|
||||||
--bg-overlay: #171717;
|
|
||||||
--bg-subtle: #1e1e1e;
|
|
||||||
|
|
||||||
--border-dim: #252525;
|
|
||||||
--border-base: #303030;
|
|
||||||
--border-strong: #3e3e3e;
|
|
||||||
--border-focus: #5a7a5a;
|
|
||||||
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #e8e6e0;
|
|
||||||
--text-muted: #b0aea8;
|
|
||||||
--text-faint: #6e6c68;
|
|
||||||
--text-disabled: #303030;
|
|
||||||
|
|
||||||
--accent: #7aaa7a;
|
|
||||||
--accent-dim: #2e4a2e;
|
|
||||||
--accent-muted: #1e2e1e;
|
|
||||||
--accent-fg: #bcd8bc;
|
|
||||||
--accent-bright: #9fcf9f;
|
|
||||||
}
|
|
||||||