Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| 9f8bf6ffc1 | |||
| 39f813b4d7 | |||
| 18ac38e888 | |||
| 1e2e923eab | |||
| d3a40b9152 | |||
| b1444582a3 | |||
| bf3f68b996 | |||
| 4b728ad5b7 | |||
| f3f91f1555 | |||
| 062662781a | |||
| cbf8a7fe13 | |||
| 5af80213c7 | |||
| 17d739a1cd | |||
| 2867dc9612 | |||
| a9dc047b44 | |||
| ef190ae66f | |||
| 6d921944ac | |||
| 244447da9b | |||
| f05f781b5b | |||
| f7c5aebf29 | |||
| e09ae9d2e7 | |||
| 7b2ae74c02 | |||
| 0d53e3f102 | |||
| 093b395cc1 | |||
| efdd8ff95d | |||
| c0f0ff9bd3 | |||
| 3f6049c12d | |||
| 5451a2654b | |||
| e625755c5e | |||
| bd95bf4eb1 | |||
| b4d680ddd1 | |||
| d1b7429b5d | |||
| 000195be89 | |||
| 399d429142 | |||
| b79ee99e8a | |||
| 80c4b9d9be | |||
| 4584e6e69e | |||
| 83711c155d | |||
| 3702a25813 | |||
| a71cc719ba | |||
| 1801fecdbb | |||
| 0cd799f450 | |||
| 5dab7761bc | |||
| 552a11a517 | |||
| c8ec6d6b90 | |||
| daaeae00fe | |||
| 79cb2f7c56 | |||
| 4d3dfdbec6 | |||
| 78573eacb1 | |||
| 1bb7da3b22 | |||
| dd0cf9372d | |||
| 50928c6343 | |||
| 170493aa71 | |||
| c009bd71fc | |||
| 4df7f416a7 | |||
| 63209cb828 | |||
| 2c1391c378 | |||
| 41fd4a820c | |||
| 9f3c6d2ac3 | |||
| f5b3f76b5d | |||
| 528f966b1f | |||
| 75e8bc5986 | |||
| 8123053a40 | |||
| e33464b05b | |||
| 6f15e8fbc2 | |||
| 86c6558bab | |||
| c041f99c75 | |||
| 84c2a82c2c | |||
| dc174bee4a | |||
| 72496a25e2 | |||
| 8b074e4b97 | |||
| 743f14f561 | |||
| d9ae94f0ff | |||
| 045bcc5bc4 | |||
| 4004a49cfb | |||
| ee72e345bd | |||
| 336ab0a24f | |||
| c3b015f00f | |||
| 22e3095cf5 | |||
| 7bc2050971 | |||
| d26f0b85e3 | |||
| e8e6f18851 | |||
| 50c5131477 | |||
| c0efbba4df | |||
| 361a145702 | |||
| 1c004d7e5c | |||
| fb72e45817 | |||
| 5c2e2b6866 | |||
| 4b313512d4 | |||
| 63258b2aa1 | |||
| b5f96a3a5c | |||
| 4eef03cbb1 | |||
| f6118077fb | |||
| 544792a7ad | |||
| e063369dfb | |||
| 514910667b | |||
| 2e9939c4a9 | |||
| 581aea5694 | |||
| 72a88b10c8 | |||
| 371b4af73f | |||
| 634d32f372 | |||
| 4e6be5d9f5 | |||
| bb7256c4f8 | |||
| b12ff4cbaa | |||
| 63a829ddca | |||
| 94b14fb7f6 | |||
| bd2fd7a6d7 | |||
| 6634ad56d2 | |||
| 2eb8a7662e | |||
| 7dd4f52308 | |||
| 690f59c602 | |||
| c025336a7e | |||
| 86c78689df | |||
| 2d3a4d0e57 | |||
| 1a5c63a607 | |||
| 3f7102556b | |||
| f5a66ab5d1 | |||
| e41e8011be | |||
| 044c93a790 | |||
| e49df4501f | |||
| 4b97f4a6c9 | |||
| 005680394e | |||
| ecb4748414 | |||
| f0dc3446b2 | |||
| 8507c34b21 | |||
| 78da5915df | |||
| c0c486a53e | |||
| 236d6bcf08 | |||
| 2b140ae022 | |||
| 38d407092f | |||
| 12191dfcdf | |||
| 13a2f9ecb7 | |||
| c573c54318 | |||
| ff5fcc4fc0 | |||
| 1aad4a1ff0 | |||
| 68a9331b6f | |||
| 64f63ceaa2 | |||
| 6d835914ef | |||
| 10f5936dbd | |||
| 5ddbfdbd6d | |||
| 0ff148f720 | |||
| d98ca76036 | |||
| 35650481b0 | |||
| 8b16537c35 | |||
| 96639d2152 | |||
| 1c135a79ca | |||
| 6c11a9d53e | |||
| 5a2f88b806 | |||
| 75430305e6 | |||
| ea76b5fc26 | |||
| d5d9ff8b6e | |||
| 7c9182eb4b | |||
| 4d6ebe8804 | |||
| 49562c3f76 | |||
| 4a299f60ac | |||
| de397f2462 | |||
| af29cffdff | |||
| f840ae6413 | |||
| 6b8d4fc05f | |||
| 15079f7755 | |||
| 1a08d2415f | |||
| 7917491389 | |||
| 0b6e9fbbbb | |||
| 023b23288b | |||
| 67a9f0b944 | |||
| 56392e2427 | |||
| 843e205072 | |||
| ee708d85d0 | |||
| 8005c82654 | |||
| d989b2d67e | |||
| 6446a19b2d | |||
| 5cd96abc0c | |||
| db44afc4dc | |||
| 4248e344ab | |||
| 8941bfef10 | |||
| 11cd6ff870 | |||
| 15adb02be3 | |||
| 51bb6cdab9 | |||
| 454a674ada | |||
| f146de5c02 | |||
| 04f680c3bb | |||
| f49f7e7ac1 | |||
| a62512bf42 | |||
| d91ed2e6d1 | |||
| 61e3c4ee2f | |||
| 9151820843 | |||
| 63c890dadf | |||
| 51a33679d5 | |||
| 82f8a9a36b | |||
| 4decce9a7f | |||
| a69d5eacc5 | |||
| 4959722759 | |||
| 35ba0171c7 | |||
| d26fa50e76 | |||
| fd9d216325 | |||
| 581eb2adb0 | |||
| 8aa2dc2547 | |||
| 0a11fe3982 | |||
| f6786def87 | |||
| 262027d9f9 | |||
| d407359973 | |||
| a77572a8d4 | |||
| 32d2fffdc5 | |||
| e850cbac1e | |||
| eebd1b6446 | |||
| 5ed072211b | |||
| 62e41e5f07 | |||
| 4b6d0780c9 | |||
| 6ef0facb89 | |||
| 34d997fc9d | |||
| 1f08b46919 | |||
| ac6b70fb32 | |||
| 2c93d8743d | |||
| b9fe54c08d | |||
| 3abb4bb96c | |||
| 4b3493465d | |||
| 2163f4a8a6 | |||
| fc535f3f74 | |||
| c819d03222 | |||
| b23292cff5 | |||
| 6d85be751a | |||
| 06a9e71a90 | |||
| 1a183e7a24 | |||
| dcb3377349 | |||
| 077ea4dd8f | |||
| 6bdf59db6a | |||
| db9ff33c64 | |||
| fb1b3d9789 | |||
| 041f735a6e | |||
| a27c20fabf | |||
| 29323c534b | |||
| a3ef693ed8 | |||
| 4691f3aed7 | |||
| 06cb70048b | |||
| d3e62a7a08 | |||
| b6ef2b1b3c | |||
| c13a4eb77a | |||
| bd972eccf3 | |||
| 9610c0294d | |||
| 406819ccca | |||
| 272e026210 | |||
| 57bf9d5fb1 | |||
| 7df7191799 | |||
| e6b542cd6b | |||
| 4903b066b1 | |||
| 96bac1ad2b | |||
| 94b92d000f | |||
| 43630ef72d | |||
| 161b1f9f52 | |||
| 816b384d64 | |||
| b772b94c6c | |||
| deb8a5ee02 | |||
| 821e13fc44 | |||
| 937054d674 | |||
| 4532b37201 | |||
| 73b73e85d7 | |||
| 697116b630 | |||
| 0e87c51801 | |||
| bf38e00cf3 | |||
| eb7360ee05 | |||
| c9eba3da86 | |||
| fc68d3ac7e | |||
| 1fa1c3a2e0 | |||
| 8c38330143 | |||
| 272d7673ce | |||
| 3d074a1fb1 | |||
| be15cb6ad8 | |||
| 3aee69939b | |||
| 0557f3f2d6 | |||
| 817af0d10a | |||
| 70afb08f83 | |||
| f751f34c68 | |||
| 8c9d3fc783 | |||
| 0f0cd87e6d | |||
| f5a1b13e43 | |||
| 4fca379715 | |||
| ac5e3ae53b | |||
| 6d39d5574a | |||
| 5e8f0d2f52 | |||
| 87e2009d4e | |||
| 2f5103c48c | |||
| 9d9c1b61e7 | |||
| a1a0f360d7 | |||
| 9a0afed2b0 | |||
| 28e9e3bcf8 | |||
| ac04c39ead |
@@ -1,66 +0,0 @@
|
|||||||
name: Build AppImage
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version tag (e.g. 0.1.0)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# ubuntu-20.04 ships webkit2gtk 2.44 by default, avoiding the
|
|
||||||
# EGL_BAD_PARAMETER crash present in 2.46+
|
|
||||||
# https://github.com/gitbutlerapp/gitbutler/issues/5282
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
patchelf \
|
|
||||||
file
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Setup Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: pnpm tauri build --bundles appimage
|
|
||||||
env:
|
|
||||||
NO_STRIP: "true"
|
|
||||||
|
|
||||||
- name: Upload AppImage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Moku-${{ github.event.inputs.version || github.sha }}-amd64.AppImage
|
|
||||||
path: src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Linux x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-linux
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libappindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libfuse2
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Linux x64)
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-linux-x64.tar.gz" \
|
||||||
|
-o suwayomi-linux.tar.gz
|
||||||
|
|
||||||
|
echo "888bee202649ce7e3e3468a729c4084fb465f024b4033cab3f8ab98b0c66fe76 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
|
||||||
|
|
||||||
|
JAR="suwayomi-extracted/bin/Suwayomi-Server.jar"
|
||||||
|
JAVA="suwayomi-extracted/jre/bin/java"
|
||||||
|
CATCH="suwayomi-extracted/bin/catch_abort.so"
|
||||||
|
|
||||||
|
for f in "$JAR" "$JAVA" "$CATCH"; do
|
||||||
|
if [ ! -e "$f" ]; then
|
||||||
|
echo "ERROR: expected file not found: $f"
|
||||||
|
find suwayomi-extracted -type f | head -40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "JAR=$JAR JAVA=$JAVA CATCH=$CATCH"
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
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'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file" \
|
||||||
|
"https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
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_asset "$APPIMAGE" "moku-linux-x64-${VERSION}.AppImage"
|
||||||
|
[ -n "$DEB" ] && upload_asset "$DEB" "moku-linux-x64-${VERSION}.deb"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to build (e.g. 0.4.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
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (macOS)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi binaries
|
||||||
|
run: |
|
||||||
|
download_suwayomi() {
|
||||||
|
local asset="$1" sha="$2" outdir="$3"
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||||
|
-o "${outdir}.tar.gz"
|
||||||
|
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||||
|
mkdir -p "${outdir}"
|
||||||
|
tar -xzf "${outdir}.tar.gz" -C "${outdir}" --strip-components=1
|
||||||
|
}
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||||
|
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||||
|
"suwayomi-arm64"
|
||||||
|
|
||||||
|
download_suwayomi \
|
||||||
|
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||||
|
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||||
|
"suwayomi-x64"
|
||||||
|
|
||||||
|
- name: Stage Suwayomi sidecars
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
|
||||||
|
stage_arch() {
|
||||||
|
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)
|
||||||
|
JAVA=$(find "$srcdir" -path "*/jre/bin/java" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$JAR" ]; then
|
||||||
|
echo "ERROR: Suwayomi-Server.jar not found in $srcdir"
|
||||||
|
find "$srcdir" -type f | head -30
|
||||||
|
exit 1
|
||||||
|
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_arch suwayomi-arm64 aarch64-apple-darwin
|
||||||
|
stage_arch suwayomi-x64 x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Patch tauri.conf.json for CI
|
||||||
|
run: |
|
||||||
|
sed -i '' 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Swap bundle for aarch64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-aarch64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (aarch64)
|
||||||
|
run: pnpm tauri build --target aarch64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Swap bundle for x86_64
|
||||||
|
run: |
|
||||||
|
rm -rf src-tauri/binaries/suwayomi-bundle
|
||||||
|
cp -r src-tauri/binaries/suwayomi-bundle-x86_64-apple-darwin \
|
||||||
|
src-tauri/binaries/suwayomi-bundle
|
||||||
|
|
||||||
|
- name: Build Tauri app (x86_64)
|
||||||
|
run: pnpm tauri build --target x86_64-apple-darwin --config src-tauri/tauri.macos.conf.json
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }}
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
# Wait for the Windows workflow to have created the draft release
|
||||||
|
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'"$VERSION"'") | .id' | head -1)
|
||||||
|
if [ -n "$RELEASE_ID" ]; then break; fi
|
||||||
|
echo "Waiting for release to exist... attempt $i"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: Could not find release for v$VERSION after waiting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found release ID: $RELEASE_ID"
|
||||||
|
|
||||||
|
upload_asset() {
|
||||||
|
local file="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "Uploading $name..."
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/octet-stream" --data-binary @"$file" "https://uploads.github.com/repos/moku-project/Moku/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM64_DMG=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
X64_DMG=$(find src-tauri/target/x86_64-apple-darwin/release/bundle/dmg -name "*.dmg" | head -1)
|
||||||
|
|
||||||
|
[ -n "$ARM64_DMG" ] && upload_asset "$ARM64_DMG" "moku-macos-arm64-${VERSION}.dmg"
|
||||||
|
[ -n "$X64_DMG" ] && upload_asset "$X64_DMG" "moku-macos-x64-${VERSION}.dmg"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
tauri:
|
||||||
|
name: Tauri (Windows x64)
|
||||||
|
needs: frontend
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-windows
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download Suwayomi (Windows x64)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||||
|
-o suwayomi-windows.zip
|
||||||
|
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||||
|
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||||
|
|
||||||
|
- name: Extract Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p suwayomi-extracted
|
||||||
|
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
|
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then
|
||||||
|
INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||||
|
cp -r "$INNER"/. suwayomi-extracted/
|
||||||
|
else
|
||||||
|
cp -r suwayomi-raw/. suwayomi-extracted/
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Stage Suwayomi bundle
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1)
|
||||||
|
JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1)
|
||||||
|
if [ -z "$JAVA" ]; then
|
||||||
|
echo "ERROR: jre/bin/java.exe not found"
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Delete existing draft release if present
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||||
|
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
echo "Deleting existing draft release $RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/releases/$RELEASE_ID"
|
||||||
|
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/moku-project/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||||
|
echo "Deleted draft release and tag"
|
||||||
|
else
|
||||||
|
echo "No existing draft release found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app + create draft release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: v${{ github.event.inputs.version }}
|
||||||
|
releaseName: Moku v${{ github.event.inputs.version }}
|
||||||
|
releaseBody: |
|
||||||
|
Moku v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
**Windows:** Download `Moku_${{ github.event.inputs.version }}_x64-setup.exe`
|
||||||
|
**macOS arm64:** Download `moku-macos-arm64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**macOS x64:** Download `moku-macos-x64-${{ github.event.inputs.version }}.dmg`
|
||||||
|
**Linux:** Download `moku.flatpak`
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||||
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright [2026] [@Youwes09]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
pkgname=moku
|
pkgname=moku
|
||||||
pkgver=0.2.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'
|
||||||
@@ -18,13 +18,13 @@ makedepends=(
|
|||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
"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"
|
)
|
||||||
|
sha256sums=(
|
||||||
|
'4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684'
|
||||||
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
sha256sums=('dfd110ae4f11711ce979020ae65b08ab2d0bd51ecc1ba877ba1780ba037357a4'
|
|
||||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
|
||||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
@@ -33,14 +33,7 @@ prepare() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Repack dist for Tauri
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
|
|
||||||
# Build Tauri binary
|
|
||||||
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
|
||||||
@@ -49,24 +42,17 @@ build() {
|
|||||||
package() {
|
package() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
|
|
||||||
# Moku binary
|
|
||||||
install -Dm755 src-tauri/target/release/moku \
|
install -Dm755 src-tauri/target/release/moku \
|
||||||
"$pkgdir/usr/bin/moku"
|
"$pkgdir/usr/bin/moku"
|
||||||
|
|
||||||
# Bundled JRE
|
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
|
||||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
|
||||||
|
|
||||||
# Suwayomi server jar
|
|
||||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
|
||||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||||
|
|
||||||
# tachidesk-server wrapper script
|
|
||||||
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 = true
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -74,11 +60,11 @@ 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
|
||||||
@@ -100,26 +86,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
|
||||||
|
|
||||||
# Desktop entry and icons
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
install -Dm644 packaging/dev.moku.app.desktop \
|
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||||
"$pkgdir/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png \
|
install -Dm644 src-tauri/icons/32x32.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128.png \
|
install -Dm644 src-tauri/icons/128x128.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,167 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/rounded-logo.png" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
|
|
||||||
|
|
||||||
<table>
|
<div align="center">
|
||||||
<tr>
|
|
||||||
<td><img src=".github/screenshots/Library-Page.png" width="100%" /></td>
|
[](https://github.com/moku-project/Moku/releases/latest)
|
||||||
<td><img src=".github/screenshots/Libary-Browse.png" width="100%" /></td>
|

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

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**winget:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Moku.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||||
|
|
||||||
|
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
|
|
||||||
|
### Linux (Flatpak, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install io.github.moku_app.Moku
|
||||||
|
```
|
||||||
|
|
||||||
|
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install moku.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:moku-project/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:moku-project/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### macOS
|
||||||
|
|
||||||
```bash
|
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
nix build
|
> ```bash
|
||||||
./result/bin/moku
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
```
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/moku-project/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm tauri:dev
|
pnpm tauri:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
|
||||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
|
[](https://discord.gg/x97hj8zR72)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
|
|||||||
@@ -1,89 +1,39 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
- Moku-Share allows exporting of Manga
|
||||||
5. Investigate "egl:failed to create dri2 screen"
|
- Compressed Format (Storage)
|
||||||
|
- Import as Local-Source
|
||||||
|
- Takes existing Local-Source or Creates Own
|
||||||
|
|
||||||
Bugs:
|
Minor Revisions:
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
|
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||||
|
|
||||||
-
|
Priority Bugs:
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
- Fix Library-Refresh System (TESTING)
|
||||||
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
|
|
||||||
- Add Back after Search & Clear on Search
|
|
||||||
- Add as Package in Nix Flake & Check Later
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
|
|
||||||
|
- Suwayomi RESET
|
||||||
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
|
- If Possible, Component based Wipe (Library, Etc)
|
||||||
|
|
||||||
- Fix Mangafire Main Dispatcher Issue
|
Pending/On-Hold:
|
||||||
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
|
- Working on 3D Display Cards
|
||||||
|
- Add Flathub Support (Pending Video)
|
||||||
|
|
||||||
|
- Change Auto-Link Threshold
|
||||||
|
- Fix Auto-Link De-dupe for Images
|
||||||
|
- Optimize Auto-Link Latency (IP)
|
||||||
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
In-Progress:
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Fix Tracking Login
|
||||||
|
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||||
|
|
||||||
- Clean up Migrate Model to be more initutive
|
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||||
|
- Note User's have to always install extensions manually
|
||||||
|
- Create "Missing Source" for Manga
|
||||||
|
|
||||||
Features:
|
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||||
- Add PDF Textbook Support
|
|
||||||
- Major revision to disable entire manga-subsection and use as
|
|
||||||
solely as a reader/document launcher.
|
|
||||||
- Multiple Tag Filters + Mor Tags, Types, Etc
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Properly Kill Tachidesk-Server
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
-
|
|
||||||
|
|
||||||
|
- UI LOGIN DOES NOT WORK OFFLINE
|
||||||
Big Revisions:
|
Notes from last time:
|
||||||
0. Expand into fully-fledged reader, with modular manga support
|
|
||||||
1. Anime & Novel Support
|
|
||||||
2. Tracker Support
|
|
||||||
3. Cloudflare Bypass Enable Support
|
|
||||||
4. macOS Support (feasible)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Testing:
|
|
||||||
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
|
|
||||||
5. Lock reader on valid chapters to avoid bugs, etc.
|
|
||||||
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
|
|
||||||
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
|
|
||||||
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
|
||||||
7. Fix Scaling (100 = 125% and so forth)
|
|
||||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
|
||||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
|
||||||
11. Reader & UI needs download and other Notifications
|
|
||||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
|
||||||
- Add Refresh Details on Series Details.
|
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
|
||||||
- Remove Series Detail Mark Read & Unread
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
|
||||||
cd ~/Projects/Manga/Moku
|
|
||||||
pnpm build
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
|
||||||
|
|
||||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
|
||||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# build-scripts/release.sh
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Usage:
|
|
||||||
# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak)
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only
|
|
||||||
# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only
|
|
||||||
#
|
|
||||||
# Requires: nix, podman (for AUR .SRCINFO generation in Arch container)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Colour helpers ─────────────────────────────────────────────────────────────
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${CYAN} →${RESET} $*"; }
|
|
||||||
success() { echo -e "${GREEN} ✓${RESET} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW} ⚠${RESET} $*"; }
|
|
||||||
die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; }
|
|
||||||
section() { echo -e "\n${BOLD}── $* ──${RESET}"; }
|
|
||||||
|
|
||||||
# ── Args ───────────────────────────────────────────────────────────────────────
|
|
||||||
[[ $# -lt 1 ]] && die "Usage: $0 <version> [--aur|--flatpak]"
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
MODE="${2:-all}"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
AUR_DIR="${REPO_ROOT}/../moku-bin"
|
|
||||||
TARBALL="moku-${VERSION}-x86_64.tar.gz"
|
|
||||||
FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml"
|
|
||||||
|
|
||||||
# ── Sanity checks ──────────────────────────────────────────────────────────────
|
|
||||||
section "Pre-flight"
|
|
||||||
command -v nix &>/dev/null || die "nix not found"
|
|
||||||
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)"
|
|
||||||
[[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin"
|
|
||||||
[[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR"
|
|
||||||
fi
|
|
||||||
success "OK"
|
|
||||||
|
|
||||||
# ── Bump versions ──────────────────────────────────────────────────────────────
|
|
||||||
section "Bumping version → ${VERSION}"
|
|
||||||
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \
|
|
||||||
"${REPO_ROOT}/src-tauri/tauri.conf.json"
|
|
||||||
success "tauri.conf.json → ${VERSION}"
|
|
||||||
|
|
||||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \
|
|
||||||
"${REPO_ROOT}/src-tauri/Cargo.toml"
|
|
||||||
success "Cargo.toml → ${VERSION}"
|
|
||||||
|
|
||||||
# ── Build frontend ─────────────────────────────────────────────────────────────
|
|
||||||
section "Building frontend"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
nix develop --command pnpm install --frozen-lockfile
|
|
||||||
nix develop --command pnpm build
|
|
||||||
success "Frontend built → dist/"
|
|
||||||
|
|
||||||
# ── Build Rust binary ──────────────────────────────────────────────────────────
|
|
||||||
section "Building Rust binary"
|
|
||||||
nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml
|
|
||||||
|
|
||||||
BINARY="${REPO_ROOT}/src-tauri/target/release/moku"
|
|
||||||
[[ -f "$BINARY" ]] || die "Binary not found: $BINARY"
|
|
||||||
success "Binary → $BINARY"
|
|
||||||
|
|
||||||
# ── Flatpak ────────────────────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then
|
|
||||||
section "Regenerating cargo-sources.json"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
nix-shell \
|
|
||||||
-p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \
|
|
||||||
--run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
success "cargo-sources.json updated"
|
|
||||||
|
|
||||||
section "Rebuilding frontend-dist.tar.gz"
|
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
|
||||||
FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}')
|
|
||||||
success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}"
|
|
||||||
|
|
||||||
# Patch the sha256 in dev.moku.app.yml automatically via a temp script
|
|
||||||
PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py)
|
|
||||||
cat > "$PATCH_SCRIPT" << PYEOF
|
|
||||||
import re, sys
|
|
||||||
|
|
||||||
path = "${FLATPAK_MANIFEST}"
|
|
||||||
new_sha = "${FRONTEND_SHA}"
|
|
||||||
text = open(path).read()
|
|
||||||
|
|
||||||
pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+'
|
|
||||||
replacement = r'\g<1>' + new_sha
|
|
||||||
updated, n = re.subn(pattern, replacement, text)
|
|
||||||
if n == 0:
|
|
||||||
sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml")
|
|
||||||
open(path, 'w').write(updated)
|
|
||||||
PYEOF
|
|
||||||
nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'"
|
|
||||||
rm -f "$PATCH_SCRIPT"
|
|
||||||
success "dev.moku.app.yml sha256 updated"
|
|
||||||
|
|
||||||
section "Building Flatpak bundle"
|
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
|
||||||
|
|
||||||
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \
|
|
||||||
flatpak-builder \
|
|
||||||
--repo="${REPO_ROOT}/repo" \
|
|
||||||
--force-clean \
|
|
||||||
"${REPO_ROOT}/build-dir" \
|
|
||||||
"$FLATPAK_MANIFEST"
|
|
||||||
|
|
||||||
flatpak build-bundle \
|
|
||||||
"${REPO_ROOT}/repo" \
|
|
||||||
"${REPO_ROOT}/moku.flatpak" \
|
|
||||||
dev.moku.app
|
|
||||||
|
|
||||||
# Clean up intermediate build artefacts — keep only moku.flatpak
|
|
||||||
rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo"
|
|
||||||
success "moku.flatpak created"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── AUR tarball + PKGBUILD ─────────────────────────────────────────────────────
|
|
||||||
if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then
|
|
||||||
section "Assembling release tarball"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
STAGE="release-${VERSION}"
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
|
|
||||||
install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku"
|
|
||||||
install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop"
|
|
||||||
install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png"
|
|
||||||
install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml"
|
|
||||||
|
|
||||||
tar -czf "$TARBALL" "$STAGE/"
|
|
||||||
AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
success "Tarball: ${TARBALL} sha256: ${AUR_SHA}"
|
|
||||||
|
|
||||||
section "Patching PKGBUILD"
|
|
||||||
PKGBUILD="${AUR_DIR}/PKGBUILD"
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
|
|
||||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
|
||||||
sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD"
|
|
||||||
success "PKGBUILD patched"
|
|
||||||
|
|
||||||
# Tarball is only needed for the GitHub upload — remind user then it can go
|
|
||||||
info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted"
|
|
||||||
|
|
||||||
section "Generating .SRCINFO (Arch container)"
|
|
||||||
# Mount only the AUR dir into a throwaway Arch container and run makepkg
|
|
||||||
podman run --rm \
|
|
||||||
--volume "${AUR_DIR}:/aur:z" \
|
|
||||||
--workdir /aur \
|
|
||||||
archlinux:latest \
|
|
||||||
bash -c "
|
|
||||||
pacman -Sy --noconfirm pacman >/dev/null 2>&1
|
|
||||||
source PKGBUILD
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
"
|
|
||||||
success ".SRCINFO generated"
|
|
||||||
|
|
||||||
section "Next steps"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}"
|
|
||||||
echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}2. Push AUR:${RESET}"
|
|
||||||
echo -e " ${CYAN}cd ${AUR_DIR}${RESET}"
|
|
||||||
echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}"
|
|
||||||
echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}"
|
|
||||||
echo -e " ${CYAN}git push origin master${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}3. Clean up:${RESET}"
|
|
||||||
echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
success "v${VERSION} ready"
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#52b888"/>
|
||||||
|
<stop offset="100%" stop-color="#1e5840"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="roundedBounds">
|
||||||
|
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clip-path="url(#roundedBounds)">
|
||||||
|
|
||||||
|
<rect width="1280" height="320" fill="#070e09"/>
|
||||||
|
|
||||||
|
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||||
|
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||||
|
fill="url(#leafHero)" opacity="0.97">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Stack text pinned to bottom -->
|
||||||
|
<text
|
||||||
|
x="640" y="300"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
font-size="14"
|
||||||
|
letter-spacing="5"
|
||||||
|
fill="#a8c4a8"
|
||||||
|
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771438068,
|
"lastModified": 1773857772,
|
||||||
"narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=",
|
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597",
|
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -15,32 +15,16 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-compat": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1733328505,
|
|
||||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
|
||||||
"owner": "edolstra",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "edolstra",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769996383,
|
"lastModified": 1772408722,
|
||||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -49,53 +33,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nix-appimage": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-compat": "flake-compat",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1757920913,
|
|
||||||
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
|
|
||||||
"owner": "ralismark",
|
|
||||||
"repo": "nix-appimage",
|
|
||||||
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ralismark",
|
|
||||||
"repo": "nix-appimage",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771369470,
|
"lastModified": 1773821835,
|
||||||
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
|
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0182a361324364ae3f436a63005877674cf45efb",
|
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -107,11 +51,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769909678,
|
"lastModified": 1772328832,
|
||||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -124,7 +68,6 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
"crane": "crane",
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nix-appimage": "nix-appimage",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
@@ -136,11 +79,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771556776,
|
"lastModified": 1773975983,
|
||||||
"narHash": "sha256-zKprqMQDl3xVfhSSYvgru1IGXjFdxryWk+KqK0I20Xk=",
|
"narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "8b3f46b8a6d17ab46e533a5e3d5b1cc2ff228860",
|
"rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -148,21 +91,6 @@
|
|||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -9,14 +9,10 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
nix-appimage = {
|
|
||||||
url = "github:ralismark/nix-appimage";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ flake-parts, crane, rust-overlay, nix-appimage, ... }:
|
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
@@ -24,21 +20,23 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{ system, pkgs, lib, ... }:
|
{ system, lib, ... }:
|
||||||
let
|
let
|
||||||
pkgs' = import inputs.nixpkgs {
|
version = "0.9.4";
|
||||||
|
|
||||||
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
};
|
};
|
||||||
|
|
||||||
rustToolchain = pkgs'.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [
|
extensions = [
|
||||||
"rust-src"
|
"rust-src"
|
||||||
"rust-analyzer"
|
"rust-analyzer"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs').overrideToolchain rustToolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
|
|
||||||
runtimeLibs = with pkgs; [
|
runtimeLibs = with pkgs; [
|
||||||
webkitgtk_4_1
|
webkitgtk_4_1
|
||||||
@@ -57,93 +55,57 @@
|
|||||||
|
|
||||||
frontendSrc = lib.cleanSourceWith {
|
frontendSrc = 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)
|
||||||
|| base == "index.html"
|
|| base == "index.html"
|
||||||
|| base == "package.json"
|
|| base == "package.json"
|
||||||
|| base == "pnpm-lock.yaml"
|
|| base == "pnpm-lock.yaml"
|
||||||
|| base == "tsconfig.json"
|
|| base == "tsconfig.json"
|
||||||
|| base == "tsconfig.node.json"
|
|| base == "vite.config.ts";
|
||||||
|| base == "vite.config.ts"
|
|
||||||
|| base == "postcss.config.js"
|
|
||||||
|| base == "postcss.config.cjs"
|
|
||||||
|| base == "tailwind.config.js"
|
|
||||||
|| base == "tailwind.config.ts";
|
|
||||||
};
|
|
||||||
|
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
version = "0.1.0";
|
|
||||||
src = frontendSrc;
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
nodejs_22
|
|
||||||
pnpm
|
|
||||||
pnpmConfigHook
|
|
||||||
];
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
|
||||||
pname = "moku-frontend";
|
|
||||||
version = "0.1.0";
|
|
||||||
src = frontendSrc;
|
|
||||||
fetcherVersion = 1;
|
|
||||||
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoSrc = lib.cleanSourceWith {
|
cargoSrc = lib.cleanSourceWith {
|
||||||
src = ./src-tauri;
|
src = ./src-tauri;
|
||||||
filter = path: type:
|
filter =
|
||||||
|
path: type:
|
||||||
(craneLib.filterCargoSources path type)
|
(craneLib.filterCargoSources path type)
|
||||||
|| (lib.hasInfix "/icons/" path)
|
|| (lib.hasInfix "/icons/" path)
|
||||||
|| (lib.hasInfix "/capabilities/" path)
|
|| (lib.hasInfix "/capabilities/" path)
|
||||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||||
};
|
};
|
||||||
|
|
||||||
commonArgs = {
|
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||||
src = cargoSrc;
|
|
||||||
cargoToml = ./src-tauri/Cargo.toml;
|
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
inherit version;
|
||||||
strictDeps = true;
|
src = frontendSrc;
|
||||||
buildInputs = runtimeLibs;
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
wrapGAppsHook3
|
|
||||||
];
|
|
||||||
preBuild = ''
|
|
||||||
cp -r ${frontend} ../dist
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
moku = import ./nix/moku.nix {
|
||||||
|
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
||||||
|
appIcon = ./src/assets/moku-icon.svg;
|
||||||
|
};
|
||||||
|
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||||
inherit cargoArtifacts;
|
|
||||||
meta.mainProgram = "moku";
|
|
||||||
postInstall = ''
|
|
||||||
wrapProgram $out/bin/moku \
|
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
|
||||||
pkgs.gsettings-desktop-schemas
|
|
||||||
pkgs.gtk3
|
|
||||||
]}" \
|
|
||||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
|
||||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
|
||||||
--set GDK_BACKEND wayland \
|
|
||||||
--set WEBKIT_FORCE_SANDBOX 0
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages = {
|
packages = {
|
||||||
inherit moku frontend;
|
inherit moku frontend suwayomiServer;
|
||||||
default = moku;
|
default = moku;
|
||||||
appimage = nix-appimage.bundlers."${system}".default moku;
|
};
|
||||||
|
|
||||||
|
apps = {
|
||||||
|
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||||
|
bump = { type = "app"; program = "${scripts.bump}/bin/moku-bump"; };
|
||||||
|
post-tag-bump = { type = "app"; program = "${scripts.postTagBump}/bin/moku-post-tag-bump"; };
|
||||||
|
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 {
|
||||||
@@ -154,30 +116,27 @@
|
|||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
nodejs_22
|
nodejs_22
|
||||||
pnpm
|
pnpm
|
||||||
suwayomi-server
|
suwayomiServer
|
||||||
|
cloudflared
|
||||||
xdg-utils
|
xdg-utils
|
||||||
|
(python3.withPackages (ps: [
|
||||||
|
ps.aiohttp
|
||||||
|
ps.tomlkit
|
||||||
|
]))
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
|
||||||
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}"
|
||||||
|
|
||||||
if [ ! -e /usr/bin/xdg-open ]; then
|
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||||
sudo ln -sf ${pkgs.xdg-utils}/bin/xdg-open /usr/bin/xdg-open
|
echo ""
|
||||||
fi
|
echo " nix run .#bump -- <ver>"
|
||||||
|
echo " git commit && git tag && git push"
|
||||||
LINUXDEPLOY="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage"
|
echo " nix run .#post-tag-bump -- <ver>"
|
||||||
LINUXDEPLOY_REAL="$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage.real"
|
echo " nix run .#flatpak -- <ver>"
|
||||||
if [ -f "$LINUXDEPLOY" ] && [ ! -f "$LINUXDEPLOY_REAL" ]; then
|
echo " nix run .#tunnel -- [port]"
|
||||||
mv "$LINUXDEPLOY" "$LINUXDEPLOY_REAL"
|
|
||||||
printf '#!/bin/sh\nexec ${pkgs.appimage-run}/bin/appimage-run "%s" "$@"\n' "$LINUXDEPLOY_REAL" > "$LINUXDEPLOY"
|
|
||||||
chmod +x "$LINUXDEPLOY"
|
|
||||||
echo "linuxdeploy wrapped with appimage-run"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Moku dev shell"
|
|
||||||
echo " pnpm install && pnpm tauri:dev"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Moku</title>
|
<title>Moku</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
app-id: dev.moku.app
|
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
|
||||||
@@ -9,16 +9,22 @@ separate-locales: false
|
|||||||
|
|
||||||
finish-args:
|
finish-args:
|
||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=x11
|
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
- --share=network
|
||||||
- --socket=session-bus
|
|
||||||
- --socket=system-bus
|
- --talk-name=org.freedesktop.Notifications
|
||||||
- --filesystem=home
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
|
- --talk-name=org.freedesktop.portal.FileTransfer
|
||||||
|
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --talk-name=com.canonical.AppMenu.Registrar
|
||||||
|
- --talk-name=com.canonical.indicator.application
|
||||||
|
|
||||||
|
- --filesystem=xdg-run/discord-ipc-0:ro
|
||||||
- --filesystem=xdg-data/moku:create
|
- --filesystem=xdg-data/moku:create
|
||||||
- --talk-name=org.freedesktop.Flatpak
|
- --filesystem=xdg-download
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
append-path: /usr/lib/sdk/rust-stable/bin
|
||||||
@@ -33,13 +39,10 @@ modules:
|
|||||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz
|
||||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
sha256: 553dda64b3b1c3c16f8afe402377ffebe64fb4a1721a46ed426a91fd18185e62
|
||||||
dest-filename: jdk.tar.gz
|
dest-filename: jdk.tar.gz
|
||||||
|
|
||||||
# catch_abort.so — intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess and
|
|
||||||
# exits just that thread instead of killing the whole JVM. Official Suwayomi
|
|
||||||
# fix for headless environments. Source inlined to avoid upstream drift.
|
|
||||||
- name: catch-abort
|
- name: catch-abort
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -92,12 +95,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
|
||||||
@@ -111,7 +114,7 @@ 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
|
# Seed conf on first run
|
||||||
@@ -120,7 +123,6 @@ modules:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
||||||
# Suwayomi ignores -D JVM flags when a conf file exists on disk.
|
|
||||||
sed -i \
|
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|' \
|
||||||
@@ -128,7 +130,7 @@ modules:
|
|||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
# Append keys if absent
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.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"
|
||||||
|
|
||||||
@@ -138,8 +140,6 @@ modules:
|
|||||||
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"
|
||||||
|
|
||||||
# Intercepts SIGTRAP/SIGILL from KCEF's CEF subprocess, exits just
|
|
||||||
# that thread instead of crashing the whole JVM process.
|
|
||||||
export LD_PRELOAD="/app/lib/catch_abort.so"
|
export LD_PRELOAD="/app/lib/catch_abort.so"
|
||||||
|
|
||||||
exec /app/jre/bin/java \
|
exec /app/jre/bin/java \
|
||||||
@@ -155,8 +155,8 @@ modules:
|
|||||||
|
|
||||||
sources:
|
sources:
|
||||||
- type: file
|
- type: file
|
||||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
||||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
||||||
dest-filename: Suwayomi-Server.jar
|
dest-filename: Suwayomi-Server.jar
|
||||||
|
|
||||||
- name: moku
|
- name: moku
|
||||||
@@ -171,17 +171,19 @@ modules:
|
|||||||
- 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 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/dev.moku.app.desktop /app/share/applications/dev.moku.app.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/dev.moku.app.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/dev.moku.app.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/dev.moku.app.png
|
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: git
|
||||||
path: .
|
url: https://github.com/moku-project/Moku.git
|
||||||
|
tag: v0.9.4
|
||||||
|
commit: 9f8bf6ffc11e0808acc735132e1aeff8b3bf1e09
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
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,18 @@
|
|||||||
|
{ lib, stdenv, nodejs_22, pnpm, pnpmConfigHook, fetchPnpmDeps, version, src }:
|
||||||
|
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook ];
|
||||||
|
|
||||||
|
pnpmDeps = fetchPnpmDeps {
|
||||||
|
pname = "moku-frontend";
|
||||||
|
inherit version src;
|
||||||
|
fetcherVersion = 1;
|
||||||
|
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = "pnpm build";
|
||||||
|
installPhase = "cp -r dist $out";
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
craneLib,
|
||||||
|
pkgs,
|
||||||
|
runtimeLibs,
|
||||||
|
frontend,
|
||||||
|
suwayomiServer,
|
||||||
|
version,
|
||||||
|
cargoSrc,
|
||||||
|
appIcon,
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
commonArgs = {
|
||||||
|
src = cargoSrc;
|
||||||
|
pname = "moku";
|
||||||
|
inherit version;
|
||||||
|
strictDeps = true;
|
||||||
|
buildInputs = runtimeLibs;
|
||||||
|
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||||
|
preBuild = ''
|
||||||
|
cp -r ${frontend} ../dist
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
in
|
||||||
|
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 "${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
|
||||||
|
'';
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{ pkgs, rustToolchain, version }:
|
||||||
|
|
||||||
|
{
|
||||||
|
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)"
|
||||||
|
|
||||||
|
echo "── Bumping version fields to $VERSION ──"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/tauri.conf.json"
|
||||||
|
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||||
|
"$REPO/src-tauri/Cargo.toml"
|
||||||
|
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||||
|
"$REPO/flake.nix"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$REPO/PKGBUILD"
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" "$REPO/PKGBUILD"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Regenerating Cargo.lock ──"
|
||||||
|
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Building frontend ──"
|
||||||
|
cd "$REPO"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Repacking frontend-dist.tar.gz ──"
|
||||||
|
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||||
|
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||||
|
echo "sha256: $FRONTEND_SHA"
|
||||||
|
|
||||||
|
echo "── Regenerating cargo-sources.json ──"
|
||||||
|
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||||
|
"$REPO/src-tauri/Cargo.lock" \
|
||||||
|
-o "$REPO/packaging/cargo-sources.json"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Patching flatpak manifest ──"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
sed -i "s/tag: v[^[:space:]]*/tag: v$VERSION/" "$MANIFEST"
|
||||||
|
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||||
|
import re, sys
|
||||||
|
path, sha = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(path).read()
|
||||||
|
updated, n = re.subn(
|
||||||
|
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||||
|
r'\g<1>' + sha, text)
|
||||||
|
if n == 0:
|
||||||
|
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||||
|
open(path, 'w').write(updated)
|
||||||
|
PYEOF
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Bumped to v$VERSION — commit, tag, push, then: nix run .#post-tag-bump -- $VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
postTagBump = pkgs.writeShellApplication {
|
||||||
|
name = "moku-post-tag-bump";
|
||||||
|
runtimeInputs = with pkgs; [ gnused coreutils git curl ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#post-tag-bump -- <version>"; exit 1; }
|
||||||
|
VERSION="$1"
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
PKGBUILD="$REPO/PKGBUILD"
|
||||||
|
|
||||||
|
echo "── Resolving commit for v$VERSION ──"
|
||||||
|
COMMIT=$(git ls-remote https://github.com/moku-project/Moku.git "refs/tags/v$VERSION" \
|
||||||
|
| awk '{print $1}')
|
||||||
|
[[ -z "$COMMIT" ]] && { echo "ERROR: tag v$VERSION not found on remote"; exit 1; }
|
||||||
|
echo "commit: $COMMIT"
|
||||||
|
sed -i "s/commit: [0-9a-f]\{40\}/commit: $COMMIT/" "$MANIFEST"
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo "── Fetching PKGBUILD tarball sha256 ──"
|
||||||
|
TARBALL_URL="https://github.com/moku-project/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||||
|
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||||
|
sed -i "/sha256sums=/,/)/{ 0,/'/s/'[^']*'/'$TARBALL_SHA'/ }" "$PKGBUILD"
|
||||||
|
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||||
|
|| { echo "ERROR: PKGBUILD sha256 replacement failed"; exit 1; }
|
||||||
|
echo "Done"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "post-tag-bump complete for v$VERSION"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
flatpak = pkgs.writeShellApplication {
|
||||||
|
name = "moku-flatpak";
|
||||||
|
runtimeInputs = with pkgs; [ coreutils git appstream flatpak-builder flatpak ];
|
||||||
|
text = ''
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||||
|
REPO="$(git rev-parse --show-toplevel)"
|
||||||
|
MANIFEST="$REPO/io.github.moku_project.Moku.yml"
|
||||||
|
|
||||||
|
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||||
|
flatpak-builder \
|
||||||
|
--repo="$REPO/repo" \
|
||||||
|
--force-clean \
|
||||||
|
"$REPO/build-dir" \
|
||||||
|
"$MANIFEST"
|
||||||
|
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,46 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
fetchurl,
|
||||||
|
makeWrapper,
|
||||||
|
jdk21_headless,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
jdk = jdk21_headless;
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
pname = "suwayomi-server";
|
||||||
|
version = "2.1.2087";
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v${finalAttrs.version}/Suwayomi-Server-v${finalAttrs.version}.jar";
|
||||||
|
hash = "sha256-9YmkImdCUjlME7KJqci+aRkFv1g++39NXxUBrl6R5rM=";
|
||||||
|
};
|
||||||
|
|
||||||
|
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${finalAttrs.version}";
|
||||||
|
license = lib.licenses.mpl20;
|
||||||
|
platforms = jdk.meta.platforms;
|
||||||
|
sourceProvenance = [ lib.sourceTypes.binaryBytecode ];
|
||||||
|
mainProgram = "suwayomi-server";
|
||||||
|
};
|
||||||
|
})
|
||||||
@@ -1,41 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"private": true,
|
"version": "0.5.0",
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||||
"tauri:build": "tauri build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tauri-apps/plugin-store": "~2.4.2",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-shell": "~2",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"phosphor-svelte": "^3.1.0",
|
||||||
"react": "^18.3.1",
|
"svelte-spa-router": "^5.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||||
"react-router-dom": "^6.26.0",
|
"tauri-plugin-drpc": "^1.0.3"
|
||||||
"zustand": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@types/react": "^18.3.3",
|
"@tauri-apps/cli": "^2.11.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"svelte": "^5.55.5",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"svelte-check": "^4.4.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"typescript": "^6.0.3",
|
||||||
"postcss": "^8.4.40",
|
"vite": "^8.0.10"
|
||||||
"tailwindcss": "^3.4.7",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.4.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<component type="desktop-application">
|
|
||||||
<id>dev.moku.app</id>
|
|
||||||
<metadata_license>MIT</metadata_license>
|
|
||||||
<project_license>MIT</project_license>
|
|
||||||
|
|
||||||
<name>Moku</name>
|
|
||||||
<summary>Manga reader powered by Suwayomi</summary>
|
|
||||||
|
|
||||||
<description>
|
|
||||||
<p>
|
|
||||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
|
||||||
providing a clean native interface for browsing, reading, and managing your
|
|
||||||
manga library across hundreds of sources.
|
|
||||||
</p>
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
|
||||||
|
|
||||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
|
||||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
|
||||||
|
|
||||||
<provides>
|
|
||||||
<binary>moku</binary>
|
|
||||||
</provides>
|
|
||||||
|
|
||||||
<content_rating type="oars-1.1" />
|
|
||||||
|
|
||||||
<releases>
|
|
||||||
<release version="0.1.0" date="2025-01-01">
|
|
||||||
<description>
|
|
||||||
<p>Initial release.</p>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
</releases>
|
|
||||||
</component>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Name=Moku
|
Name=Moku
|
||||||
Comment=Manga reader powered by Suwayomi
|
Comment=Manga reader powered by Suwayomi
|
||||||
Exec=moku
|
Exec=moku
|
||||||
Icon=dev.moku.app
|
Icon=io.github.moku_project.Moku
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Graphics;Viewer;
|
Categories=Graphics;Viewer;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.moku_project.Moku</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<name>Moku</name>
|
||||||
|
<summary>Manga reader powered by Suwayomi</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||||
|
providing a clean native interface for browsing, reading, and managing your
|
||||||
|
manga library across hundreds of sources.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Features include library management, chapter tracking, extension support,
|
||||||
|
reading history, notifications, and Discord Rich Presence integration.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/moku-project/Moku</url>
|
||||||
|
<url type="bugtracker">https://github.com/moku-project/Moku/issues</url>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||||
|
<caption>Home screen showing your manga library</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||||
|
<caption>Built-in manga reader</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||||
|
<caption>Discover new manga across hundreds of sources</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||||
|
<caption>Download manager</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||||
|
<caption>Settings</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>moku</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.9.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Latest release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.8.0" date="2025-04-01">
|
||||||
|
<description>
|
||||||
|
<p>Old release with improved stability and UI refinements.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.4.0" date="2025-03-22">
|
||||||
|
<description>
|
||||||
|
<p>Svelte rewrite with improved UI, bundled server, and cross-platform fixes.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.2.0"
|
version = "0.9.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "moku_lib"
|
name = "moku_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
@@ -15,13 +15,28 @@ path = "src/main.rs"
|
|||||||
tauri-build = { version = "2.0", features = [] }
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-os = "2.3.2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
nix = { version = "0.29", features = ["fs"] }
|
sysinfo = "0.32"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
urlencoding = "2"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Security_Credentials_UI",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# — Suwayomi launcher for Linux AppImage/deb.
|
||||||
|
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||||
|
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||||
|
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||||
|
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||||
|
# We resolve relative to this script's own location.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# Tauri places resources one level up from the binary on Linux.
|
||||||
|
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||||
|
find_resource() {
|
||||||
|
for candidate in \
|
||||||
|
"${SCRIPT_DIR}" \
|
||||||
|
"${SCRIPT_DIR}/../resources" \
|
||||||
|
"${SCRIPT_DIR}/resources"
|
||||||
|
do
|
||||||
|
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DIR=$(find_resource) || {
|
||||||
|
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||||
|
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||||
|
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||||
|
|
||||||
|
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||||
|
echo "[launcher] JAVA=$JAVA" >&2
|
||||||
|
echo "[launcher] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||||
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
|
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||||
|
server.ip = "127.0.0.1"
|
||||||
|
server.port = 4567
|
||||||
|
server.webUIEnabled = true
|
||||||
|
server.initialOpenInBrowserEnabled = false
|
||||||
|
server.systemTrayEnabled = false
|
||||||
|
server.webUIInterface = "browser"
|
||||||
|
server.webUIFlavor = "WebUI"
|
||||||
|
server.webUIChannel = "PREVIEW"
|
||||||
|
server.electronPath = ""
|
||||||
|
server.debugLogsEnabled = false
|
||||||
|
server.downloadAsCbz = true
|
||||||
|
server.autoDownloadNewChapters = false
|
||||||
|
server.globalUpdateInterval = 12
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
server.extensionRepos = []
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||||
|
sed -i \
|
||||||
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||||
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# Append keys if absent (e.g. user-managed conf missing them)
|
||||||
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
|
|
||||||
|
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||||
|
unset DISPLAY
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
|
||||||
|
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
|
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||||
|
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||||
|
# bring down the whole server process (mirrors the Flatpak build).
|
||||||
|
if [ -f "$CATCH_ABORT" ]; then
|
||||||
|
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dapple.awt.UIElement=true \
|
||||||
|
-Dsun.java2d.noddraw=true \
|
||||||
|
-Dsun.awt.disablegui=true \
|
||||||
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Moku — Suwayomi launcher sidecar for macOS.
|
||||||
|
# Tauri calls this script directly as a sidecar (Contents/MacOS/suwayomi-server-{arch}).
|
||||||
|
# The Suwayomi bundle is placed by Tauri into Contents/Resources/suwayomi-bundle/.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Resolve the real directory of this script, following symlinks.
|
||||||
|
SELF="$0"
|
||||||
|
while [ -L "$SELF" ]; do
|
||||||
|
SELF="$(readlink "$SELF")"
|
||||||
|
done
|
||||||
|
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||||
|
|
||||||
|
# ── Locate the bundle ─────────────────────────────────────────────────────────
|
||||||
|
# Inside .app: sidecar = Contents/MacOS/suwayomi-server-{arch}
|
||||||
|
# bundle = Contents/Resources/suwayomi-bundle/
|
||||||
|
# Dev / flat layout: bundle sits next to the sidecar, or one level up.
|
||||||
|
find_bundle() {
|
||||||
|
local base="$1"
|
||||||
|
for candidate in \
|
||||||
|
"${base}/../Resources/suwayomi-bundle" \
|
||||||
|
"${base}/suwayomi-bundle" \
|
||||||
|
"${base}/../suwayomi-bundle"
|
||||||
|
do
|
||||||
|
# The jar lives at <bundle>/bin/Suwayomi-Server.jar
|
||||||
|
if [ -f "${candidate}/bin/Suwayomi-Server.jar" ]; then
|
||||||
|
# Canonicalise (no readlink -f on older macOS sh, use cd trick)
|
||||||
|
echo "$(cd "$candidate" && pwd)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
BUNDLE=$(find_bundle "$DIR") || {
|
||||||
|
echo "[sidecar] ERROR: cannot locate suwayomi-bundle relative to $DIR" >&2
|
||||||
|
echo "[sidecar] Tried:" >&2
|
||||||
|
echo " $DIR/../Resources/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/suwayomi-bundle" >&2
|
||||||
|
echo " $DIR/../suwayomi-bundle" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
JAVA="${BUNDLE}/jre/bin/java"
|
||||||
|
JAR="${BUNDLE}/bin/Suwayomi-Server.jar"
|
||||||
|
|
||||||
|
echo "[sidecar] BUNDLE=$BUNDLE" >&2
|
||||||
|
echo "[sidecar] JAVA=$JAVA" >&2
|
||||||
|
echo "[sidecar] JAR=$JAR" >&2
|
||||||
|
|
||||||
|
if [ ! -x "$JAVA" ]; then
|
||||||
|
echo "[sidecar] ERROR: java not executable at $JAVA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$JAR" ]; then
|
||||||
|
echo "[sidecar] ERROR: jar not found at $JAR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# "$@" will contain the -Dsuwayomi.tachidesk.config.server.rootDir=... flag
|
||||||
|
# prepended by spawn_server in lib.rs, followed by -jar <path>.
|
||||||
|
# We call java directly so all JVM flags reach it properly.
|
||||||
|
exec "$JAVA" \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
"$@" \
|
||||||
|
-jar "$JAR"
|
||||||
@@ -1,19 +1,48 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Allow launching tachidesk-server",
|
"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",
|
||||||
"identifier": "shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"allow": [
|
"shell:allow-execute",
|
||||||
{
|
"core:window:allow-minimize",
|
||||||
"name": "tachidesk-server",
|
"core:window:allow-unminimize",
|
||||||
"cmd": "tachidesk-server"
|
"core:window:allow-maximize",
|
||||||
}
|
"core:window:allow-unmaximize",
|
||||||
]
|
"core:window:allow-toggle-maximize",
|
||||||
}
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
|
"core:window:allow-is-fullscreen",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-is-minimized",
|
||||||
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-outer-size",
|
||||||
|
"core:window:allow-inner-position",
|
||||||
|
"core:window:allow-outer-position",
|
||||||
|
"core:window:allow-scale-factor",
|
||||||
|
"process:default",
|
||||||
|
"process:allow-exit",
|
||||||
|
"process:allow-restart",
|
||||||
|
"http:default",
|
||||||
|
"http:allow-fetch",
|
||||||
|
"store:default",
|
||||||
|
"discord-rpc:default",
|
||||||
|
"discord-rpc:allow-connect",
|
||||||
|
"discord-rpc:allow-disconnect",
|
||||||
|
"discord-rpc:allow-set-activity",
|
||||||
|
"discord-rpc:allow-clear-activity",
|
||||||
|
"discord-rpc:allow-is-running"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "http-scope",
|
||||||
|
"description": "HTTP fetch scope",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*:*/*" },
|
||||||
|
{ "url": "https://*:*/*" },
|
||||||
|
{ "url": "http://*/*" },
|
||||||
|
{ "url": "https://*/*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,100 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
fn backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Save Moku app data backup")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_save_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let path = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Open Moku app data backup")
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
|
.blocking_pick_file()
|
||||||
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
|
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||||
|
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("auto-moku-backup-")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||||
|
let _ = std::fs::remove_file(old.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
|
let dir = backup_dir(&app);
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_local_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let content = std::fs::read_to_string(base.join(&name))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
(name, content)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_hello {
|
||||||
|
use windows::{
|
||||||
|
core::HSTRING,
|
||||||
|
Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier},
|
||||||
|
Win32::UI::WindowsAndMessaging::{
|
||||||
|
BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
std::ffi::OsStr::new(s)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_focus_hello_dialog() -> bool {
|
||||||
|
let cls = to_wide("Credential Dialog Xaml Host");
|
||||||
|
unsafe {
|
||||||
|
let Ok(hwnd) = FindWindowW(
|
||||||
|
windows::core::PCWSTR(cls.as_ptr()),
|
||||||
|
windows::core::PCWSTR::null(),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if IsIconic(hwnd).as_bool() {
|
||||||
|
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
}
|
||||||
|
let _ = BringWindowToTop(hwnd);
|
||||||
|
let _ = SetForegroundWindow(hwnd);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nudge_focus(retries: u32, delay_ms: u64) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
for _ in 0..retries {
|
||||||
|
if try_focus_hello_dialog() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(reason: &str) -> Result<(), String> {
|
||||||
|
let reason = reason.to_owned();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
let outcome = UserConsentVerifier::RequestVerificationAsync(&HSTRING::from(reason.as_str()))
|
||||||
|
.and_then(|op| {
|
||||||
|
nudge_focus(5, 250);
|
||||||
|
op.get()
|
||||||
|
});
|
||||||
|
let _ = tx.send(outcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?
|
||||||
|
.map_err(|e| format!("internalError:{e:?}"))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
UserConsentVerificationResult::Verified => Ok(()),
|
||||||
|
UserConsentVerificationResult::Canceled => Err("userCancel".into()),
|
||||||
|
UserConsentVerificationResult::RetriesExhausted => Err("biometryLockout".into()),
|
||||||
|
UserConsentVerificationResult::DeviceBusy => Err("systemCancel".into()),
|
||||||
|
UserConsentVerificationResult::DeviceNotPresent => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::DisabledByPolicy => Err("biometryNotAvailable".into()),
|
||||||
|
UserConsentVerificationResult::NotConfiguredForUser => Err("biometryNotEnrolled".into()),
|
||||||
|
_ => Err("authenticationFailed".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_available() -> bool {
|
||||||
|
use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||||
|
UserConsentVerifier::CheckAvailabilityAsync()
|
||||||
|
.and_then(|op| op.get())
|
||||||
|
.map(|a| a == UserConsentVerifierAvailability::Available)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_authenticate(_reason: String) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::authenticate(&_reason);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
Err("notSupported".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn windows_hello_available() -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return windows_hello::is_available();
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod backup;
|
||||||
|
pub mod biometric;
|
||||||
|
pub mod server;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod system;
|
||||||
|
pub mod updater;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::server::{self, resolve::suwayomi_data_dir, SpawnError};
|
||||||
|
use crate::ServerState;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub 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();
|
||||||
|
|
||||||
|
server::do_log(
|
||||||
|
&mut log,
|
||||||
|
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
server::conf::seed_server_conf(&data_dir);
|
||||||
|
|
||||||
|
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 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,130 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use sysinfo::Disks;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
#[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,170 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use crate::server::resolve::strip_unc;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
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 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>>();
|
||||||
|
|
||||||
|
// Note: We intentionally skip the WebView2 COM-level ClearBrowsingDataAll call here.
|
||||||
|
// The webview2_com crate pulls in a different version of windows_core than Tauri's
|
||||||
|
// own windows dependency, causing irreconcilable trait-impl conflicts at compile time.
|
||||||
|
// The filesystem cache removal below (app_cache_dir) is sufficient for our purposes;
|
||||||
|
// WebView2 will rebuild its cache on next launch from a clean directory.
|
||||||
|
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,138 +1,161 @@
|
|||||||
use std::path::PathBuf;
|
mod commands;
|
||||||
|
mod server;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use nix::sys::statvfs::statvfs;
|
use std::io::{Read, Write};
|
||||||
use serde::Serialize;
|
use std::net::{TcpListener, TcpStream};
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{
|
||||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||||
use walkdir::WalkDir;
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
Manager, WindowEvent,
|
||||||
|
};
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
fn start_instance_listener(app: tauri::AppHandle) {
|
||||||
if !downloads_path.trim().is_empty() {
|
std::thread::spawn(move || {
|
||||||
return PathBuf::from(downloads_path);
|
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for stream in listener.incoming().flatten() {
|
||||||
|
handle_ipc_connection(stream, &app);
|
||||||
}
|
}
|
||||||
let base = std::env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
.join(".local/share")
|
|
||||||
});
|
});
|
||||||
base.join("Tachidesk/downloads")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
|
||||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
let mut buf = [0u8; 32];
|
||||||
let path = resolve_downloads_path(&downloads_path);
|
let Ok(n) = stream.read(&mut buf) else { return };
|
||||||
|
let msg = &buf[..n];
|
||||||
|
|
||||||
let manga_bytes = if path.exists() {
|
if !msg.starts_with(HANDSHAKE) {
|
||||||
WalkDir::new(&path)
|
return;
|
||||||
.into_iter()
|
}
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter_map(|e| e.metadata().ok())
|
let cmd = &msg[HANDSHAKE.len()..];
|
||||||
.filter(|m| m.is_file())
|
if cmd.starts_with(b"focus") {
|
||||||
.map(|m| m.len())
|
let _ = stream.write_all(b"ok\n");
|
||||||
.sum()
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
} else {
|
let _ = win.show();
|
||||||
0
|
let _ = win.unminimize();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_existing_instance() -> bool {
|
||||||
|
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
|
||||||
|
|
||||||
let stat_path = if path.exists() { path.clone() } else {
|
let mut msg = Vec::new();
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
msg.extend_from_slice(HANDSHAKE);
|
||||||
};
|
msg.extend_from_slice(FOCUS_CMD);
|
||||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let frsize = vfs.fragment_size() as u64;
|
if stream.write_all(&msg).is_err() {
|
||||||
let total_bytes = vfs.blocks() * frsize;
|
return false;
|
||||||
let free_bytes = vfs.blocks_available() * frsize;
|
|
||||||
|
|
||||||
Ok(StorageInfo {
|
|
||||||
manga_bytes,
|
|
||||||
total_bytes,
|
|
||||||
free_bytes,
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the true OS-level scale factor for the main window.
|
|
||||||
/// This reads directly from the underlying winit window handle, bypassing
|
|
||||||
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
|
|
||||||
/// This is the only reliable way to get the correct DPR in all launch
|
|
||||||
/// environments — tauri dev, nix run, flatpak, etc.
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_scale_factor(window: tauri::Window) -> f64 {
|
|
||||||
window.scale_factor().unwrap_or(1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
let mut guard = state.0.lock().unwrap();
|
|
||||||
if let Some(child) = guard.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
println!("Killed tracked server child.");
|
|
||||||
}
|
|
||||||
let _ = std::process::Command::new("pkill")
|
|
||||||
.arg("-f")
|
|
||||||
.arg("tachidesk")
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
let state = app.state::<ServerState>();
|
|
||||||
{
|
|
||||||
let guard = state.0.lock().unwrap();
|
|
||||||
if guard.is_some() {
|
|
||||||
println!("Server already running, skipping spawn.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let shell = app.shell();
|
let mut resp = [0u8; 4];
|
||||||
match shell.command(&binary).spawn() {
|
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
|
||||||
Ok((_rx, child)) => {
|
|
||||||
println!("Spawned server: {}", binary);
|
|
||||||
let mut guard = state.0.lock().unwrap();
|
|
||||||
*guard = Some(child);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to spawn {}: {}", binary, e);
|
|
||||||
Err(e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
kill_tachidesk(&app);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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_dialog::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_storage_info,
|
commands::storage::get_storage_info,
|
||||||
spawn_server,
|
commands::storage::get_default_downloads_path,
|
||||||
kill_server,
|
commands::storage::check_path_exists,
|
||||||
get_scale_factor,
|
commands::storage::create_directory,
|
||||||
|
commands::storage::migrate_downloads,
|
||||||
|
commands::server::spawn_server,
|
||||||
|
commands::server::kill_server,
|
||||||
|
commands::system::get_platform_ui_scale,
|
||||||
|
commands::system::restart_app,
|
||||||
|
commands::system::exit_app,
|
||||||
|
commands::system::clear_moku_cache,
|
||||||
|
commands::system::clear_suwayomi_cache,
|
||||||
|
commands::system::reset_suwayomi_data,
|
||||||
|
commands::system::open_path,
|
||||||
|
commands::system::pick_downloads_folder,
|
||||||
|
commands::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::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!())
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_CONF: &str = r#"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 = []
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub 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;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
|
||||||
|
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", "true"),
|
||||||
|
"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,339 @@
|
|||||||
|
use crate::server::do_log;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: path.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
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: 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
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
|
||||||
|
match find_java_in_bundle(&bundle_dir, log) {
|
||||||
|
Some(java) if jar.exists() => {
|
||||||
|
do_log(log, "[resolve] using bundled JRE");
|
||||||
|
return Ok(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(bundle_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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(ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![jar_data_root_flag(), "-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||||
|
working_dir: Some(resource_dir),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf();
|
||||||
|
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const NATIVE_NAMES: &[&str] = &[
|
||||||
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
|
"suwayomi-server",
|
||||||
|
"suwayomi-launcher",
|
||||||
|
"suwayomi-launcher.sh",
|
||||||
|
"tachidesk-server",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_binary: Option<ServerInvocation> = None;
|
||||||
|
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||||
|
|
||||||
|
'outer: for depth in 0u8..=8 {
|
||||||
|
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||||
|
.min_depth(depth as usize)
|
||||||
|
.max_depth(depth as usize)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_type().is_dir())
|
||||||
|
.map(|e| e.into_path())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for dir in &entries {
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] scanning depth={} dir={:?}", depth, dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
for name in NATIVE_NAMES {
|
||||||
|
let p = dir.join(name);
|
||||||
|
if p.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||||
|
found_binary = Some(ServerInvocation {
|
||||||
|
bin: p.to_string_lossy().into_owned(),
|
||||||
|
args: data_root_args(),
|
||||||
|
working_dir: Some(dir.clone()),
|
||||||
|
});
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found_java.is_none() {
|
||||||
|
let java_exe = dir.join("bin").join("java");
|
||||||
|
if java_exe.exists() {
|
||||||
|
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||||
|
let mut search = dir.as_path();
|
||||||
|
'jar: for _ in 0..5 {
|
||||||
|
if let Ok(rd) = std::fs::read_dir(search) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bin_sibling = search.join("bin");
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||||
|
for entry in rd.filter_map(|e| e.ok()) {
|
||||||
|
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||||
|
let jar = entry.path();
|
||||||
|
do_log(
|
||||||
|
log,
|
||||||
|
&format!("[resolve] found jar in bin/: {:?}", jar),
|
||||||
|
);
|
||||||
|
found_java = Some((java_exe.clone(), jar));
|
||||||
|
break 'jar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match search.parent() {
|
||||||
|
Some(p) => search = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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_data_root_flag(), "-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 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,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.2.0",
|
"version": "0.9.4",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "io.github.MokuProject.Moku",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm build"
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": false
|
"decorations": false,
|
||||||
|
"center": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -26,14 +27,22 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["appimage"],
|
"targets": ["nsis"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico",
|
||||||
]
|
"icons/icon.png"
|
||||||
|
],
|
||||||
|
"externalBin": [],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerIcon": "icons/icon.ico",
|
||||||
|
"installMode": "currentUser"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
|
|||||||
@@ -9,5 +9,8 @@
|
|||||||
"devtools": true
|
"devtools": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"externalBin": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage", "deb"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-launcher-linux"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||||
|
"binaries/suwayomi-bundle/jre": "jre"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"decorations": true,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["dmg"],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/suwayomi-server"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"binaries/suwayomi-bundle": "suwayomi-bundle"
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "11.0",
|
||||||
|
"exceptionDomain": "localhost",
|
||||||
|
"frameworks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"resources": [
|
||||||
|
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||||
|
"binaries/suwayomi-bundle/jre/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<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, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||||
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
|
import { boot, initStore, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
|
||||||
|
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
|
||||||
|
import { applyTheme } from "@core/theme";
|
||||||
|
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
|
||||||
|
import { checkForUpdateSilently } from "@core/updater";
|
||||||
|
import Layout from "@shared/chrome/Layout.svelte";
|
||||||
|
import Reader from "@features/reader/components/Reader.svelte";
|
||||||
|
import Settings from "@features/settings/components/Settings.svelte";
|
||||||
|
import ThemeEditor from "@features/settings/components/ThemeEditor.svelte";
|
||||||
|
import TitleBar from "@shared/chrome/TitleBar.svelte";
|
||||||
|
import Toaster from "@shared/chrome/Toaster.svelte";
|
||||||
|
import SplashScreen from "@shared/chrome/SplashScreen.svelte";
|
||||||
|
import MangaPreview from "@shared/manga/MangaPreview.svelte";
|
||||||
|
import AuthGate from "@shared/chrome/AuthGate.svelte";
|
||||||
|
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
void platform();
|
||||||
|
|
||||||
|
let appReady = $state(false);
|
||||||
|
let idle = $state(false);
|
||||||
|
let devSplash = $state(false);
|
||||||
|
|
||||||
|
let themeEditorOpen = $state(false);
|
||||||
|
let themeEditorEditId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let closeDialogOpen = $state(false);
|
||||||
|
let closeRemember = $state(false);
|
||||||
|
|
||||||
|
function openThemeEditor(id?: string | null) {
|
||||||
|
themeEditorEditId = id ?? null;
|
||||||
|
themeEditorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThemeEditor() {
|
||||||
|
themeEditorOpen = false;
|
||||||
|
themeEditorEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doQuit() {
|
||||||
|
if (store.settings.autoStartServer) {
|
||||||
|
await Promise.race([
|
||||||
|
invoke("kill_server").catch(() => {}),
|
||||||
|
new Promise(res => setTimeout(res, 2000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await invoke("exit_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doHide() {
|
||||||
|
await win.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloseRequested() {
|
||||||
|
const action = store.settings.closeAction ?? "ask";
|
||||||
|
if (action === "tray") { await doHide(); return; }
|
||||||
|
if (action === "quit") { await doQuit(); return; }
|
||||||
|
closeDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmClose(choice: "tray" | "quit") {
|
||||||
|
closeDialogOpen = false;
|
||||||
|
if (closeRemember) updateSettings({ closeAction: choice });
|
||||||
|
closeRemember = false;
|
||||||
|
if (choice === "tray") await doHide();
|
||||||
|
else await doQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 (!appReady) return;
|
||||||
|
downloadStore.poll();
|
||||||
|
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||||
|
return () => clearInterval(dlInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||||
|
|
||||||
|
await initStore();
|
||||||
|
startProbe();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||||
|
"download-progress",
|
||||||
|
e => setActiveDownloads(e.payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopProbe();
|
||||||
|
unlistenResize();
|
||||||
|
unlistenScale();
|
||||||
|
unlistenDownload();
|
||||||
|
unlistenClose();
|
||||||
|
destroyRpc();
|
||||||
|
delete (window as any).__mokuShowSplash;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if devSplash}
|
||||||
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
|
|
||||||
|
{:else if !appReady && !boot.loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||||
|
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||||
|
showCards={store.settings.splashCards ?? true}
|
||||||
|
onReady={() => { appReady = true; }}
|
||||||
|
onRetry={retryBoot}
|
||||||
|
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||||
|
|
||||||
|
{:else if boot.loginRequired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { appReady = true; }} />
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#if idle && !store.activeChapter}
|
||||||
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
|
onDismiss={() => { idle = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if boot.sessionExpired}
|
||||||
|
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||||
|
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div id="app-shell" class="root">
|
||||||
|
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/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}
|
||||||
|
|
||||||
|
{#if closeDialogOpen}
|
||||||
|
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||||
|
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="close-header">
|
||||||
|
<p class="close-title">Close Moku?</p>
|
||||||
|
<p class="close-sub">Choose how the app should exit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="close-actions">
|
||||||
|
<button class="close-btn" onclick={() => confirmClose("tray")}>
|
||||||
|
<span class="close-btn-label">Minimize to Tray</span>
|
||||||
|
<span class="close-btn-desc">Keep running in the background</span>
|
||||||
|
</button>
|
||||||
|
<button class="close-btn close-btn-danger" onclick={() => confirmClose("quit")}>
|
||||||
|
<span class="close-btn-label">Quit</span>
|
||||||
|
<span class="close-btn-desc">Stop Moku entirely</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
|
||||||
|
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
|
||||||
|
<span class="close-remember-label">Remember my choice</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
.content { flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
.close-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-dialog {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
width: 300px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255,255,255,0.04) inset,
|
||||||
|
0 20px 60px rgba(0,0,0,0.65),
|
||||||
|
0 6px 20px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-header { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
|
||||||
|
.close-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-sub {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
|
||||||
|
.close-btn-danger .close-btn-label { color: var(--color-error); }
|
||||||
|
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 55%, var(--text-faint)); }
|
||||||
|
|
||||||
|
.close-btn-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn-desc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-1) 0 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.close-remember-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-faint);
|
||||||
|
transition: transform var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-remember-toggle.on .close-remember-thumb {
|
||||||
|
transform: translateX(12px);
|
||||||
|
background: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-remember-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { gql } from "./lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
|
||||||
import "./styles/global.css";
|
|
||||||
import { useStore } from "./store";
|
|
||||||
import Layout from "./components/layout/Layout";
|
|
||||||
import Reader from "./components/pages/Reader";
|
|
||||||
import Settings from "./components/settings/Settings";
|
|
||||||
import MangaPreview from "./components/explore/MangaPreview";
|
|
||||||
import TitleBar from "./components/layout/TitleBar";
|
|
||||||
import Toaster from "./components/layout/Toaster";
|
|
||||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
|
||||||
import s from "./App.module.css";
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 30;
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const activeChapter = useStore((s) => s.activeChapter);
|
|
||||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
const addToast = useStore((s) => s.addToast);
|
|
||||||
|
|
||||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
|
||||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
|
||||||
// appReady = ring filled + transition done, show main UI
|
|
||||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
|
||||||
const [idle, setIdle] = useState(false);
|
|
||||||
// dev tools: force show splash
|
|
||||||
const [devSplash, setDevSplash] = useState(false);
|
|
||||||
|
|
||||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
|
||||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// expose devSplash trigger via window for settings
|
|
||||||
useEffect(() => {
|
|
||||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
|
||||||
return () => { delete (window as any).__mokuShowSplash; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
function resetIdle() {
|
|
||||||
setIdle(false);
|
|
||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
||||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (idleTimeoutMs === 0) return;
|
|
||||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
|
||||||
}
|
|
||||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
|
||||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
|
||||||
resetIdle();
|
|
||||||
return () => {
|
|
||||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
|
||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
||||||
};
|
|
||||||
}, [appReady, settings.idleTimeoutMin]);
|
|
||||||
|
|
||||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
|
||||||
for (const item of prev) {
|
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
|
||||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
addToast({ kind:"success", title:"Chapter downloaded",
|
|
||||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
|
||||||
duration: 4000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQueue(next: DownloadQueueItem[]) {
|
|
||||||
detectCompletions(prevQueueRef.current, next);
|
|
||||||
prevQueueRef.current = next;
|
|
||||||
setActiveDownloads(next.map(item => ({
|
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
|
||||||
}, [settings.uiScale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const theme = settings.theme ?? "dark";
|
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
|
||||||
}, [settings.theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const p = (e: MouseEvent) => e.preventDefault();
|
|
||||||
document.addEventListener("contextmenu", p);
|
|
||||||
return () => document.removeEventListener("contextmenu", p);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settings.autoStartServer) return;
|
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
|
||||||
console.warn("Could not start server:", err));
|
|
||||||
return () => { invoke("kill_server").catch(() => {}); };
|
|
||||||
}, [settings.autoStartServer, settings.serverBinary]);
|
|
||||||
|
|
||||||
// Poll until server responds
|
|
||||||
useEffect(() => {
|
|
||||||
if (serverProbeOk) return;
|
|
||||||
let cancelled = false, tries = 0;
|
|
||||||
async function probe() {
|
|
||||||
if (cancelled) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
|
||||||
method:"POST", headers:{"Content-Type":"application/json"},
|
|
||||||
body: JSON.stringify({ query:"{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
|
||||||
if (!cancelled) setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
const t = setTimeout(probe, 800);
|
|
||||||
return () => { cancelled = true; clearTimeout(t); };
|
|
||||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
|
||||||
}
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [appReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
|
||||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
|
||||||
return () => { unsub.then(fn => fn()); };
|
|
||||||
}, [setActiveDownloads]);
|
|
||||||
|
|
||||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
|
||||||
if (devSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
mode="idle"
|
|
||||||
showFps
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading splash — shown until ring fills + transition completes
|
|
||||||
if (!appReady) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
mode="loading"
|
|
||||||
ringFull={serverProbeOk}
|
|
||||||
failed={failed}
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onReady={() => setAppReady(true)}
|
|
||||||
onRetry={() => {
|
|
||||||
setFailed(false);
|
|
||||||
setServerProbeOk(false);
|
|
||||||
setRetryKey(k => k+1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
{idle && !activeChapter && (
|
|
||||||
<SplashScreen
|
|
||||||
mode="idle"
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!activeChapter && <TitleBar/>}
|
|
||||||
<div className={s.content}>
|
|
||||||
{activeChapter ? <Reader/> : <Layout/>}
|
|
||||||
</div>
|
|
||||||
{settingsOpen && <Settings/>}
|
|
||||||
<MangaPreview/>
|
|
||||||
<Toaster/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
|
||||||
|
import { boot } from "@store/boot.svelte";
|
||||||
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
|
type ReauthResolver = () => void;
|
||||||
|
let _reauthQueue: ReauthResolver[] = [];
|
||||||
|
|
||||||
|
export function notifyReauthSuccess() {
|
||||||
|
const queue = _reauthQueue;
|
||||||
|
_reauthQueue = [];
|
||||||
|
queue.forEach(resolve => resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReauth(): Promise<void> {
|
||||||
|
return new Promise(resolve => { _reauthQueue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 async function resolveImageUrl(path: string): Promise<string> {
|
||||||
|
if (!path) return "";
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "NONE") return url;
|
||||||
|
return getBlobUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const thumbUrl = plainThumbUrl;
|
||||||
|
|
||||||
|
interface GQLResponse<T> {
|
||||||
|
data: T;
|
||||||
|
errors?: { message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
retries = 3,
|
||||||
|
delayMs = 300,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
try {
|
||||||
|
const res = await fetchAuthenticated(url, init, signal, boot.skipped);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return res;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.authRequired) throw e;
|
||||||
|
if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (e instanceof AuthRequiredError) throw e;
|
||||||
|
if (i === retries - 1) throw e;
|
||||||
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImage(
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ src: string; revoke: () => void }> {
|
||||||
|
if (!path) return { src: "", revoke: () => {} };
|
||||||
|
|
||||||
|
const url = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
|
||||||
|
if (mode === "NONE") return { src: url, revoke: () => {} };
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(url, { method: "GET" }, signal);
|
||||||
|
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
return { src, revoke: () => URL.revokeObjectURL(src) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gql<T>(
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
const attempt = async (): Promise<T> => {
|
||||||
|
const res = await fetchWithRetry(
|
||||||
|
`${getServerUrl()}/api/graphql`,
|
||||||
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
const json: GQLResponse<T> = await res.json();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
if (json.errors?.length) {
|
||||||
|
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||||
|
if (isAuthError && !boot.skipped) {
|
||||||
|
boot.sessionExpired = true;
|
||||||
|
boot.loginRequired = true;
|
||||||
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
await waitForReauth();
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
|
throw new Error(json.errors[0].message);
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return attempt();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 lastReadAt 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_CHAPTER_META = `
|
||||||
|
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
|
||||||
|
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CHAPTER_META = `
|
||||||
|
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
|
||||||
|
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const QUEUE_FRAGMENT = `
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_DOWNLOAD = `
|
||||||
|
mutation EnqueueDownload($chapterId: Int!) {
|
||||||
|
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_DOWNLOAD = `
|
||||||
|
mutation DequeueDownload($chapterId: Int!) {
|
||||||
|
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||||
|
downloadStatus { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
|
||||||
|
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
|
||||||
|
dequeueChapterDownloads(input: { ids: $chapterIds }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REORDER_DOWNLOAD = `
|
||||||
|
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
|
||||||
|
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const START_DOWNLOADER = `
|
||||||
|
mutation StartDownloader {
|
||||||
|
startDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const STOP_DOWNLOADER = `
|
||||||
|
mutation StopDownloader {
|
||||||
|
stopDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_DOWNLOADER = `
|
||||||
|
mutation ClearDownloader {
|
||||||
|
clearDownloader(input: {}) {
|
||||||
|
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_SOURCE_MANGA = `
|
||||||
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
|
mangas { id title thumbnailUrl inLibrary }
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_DOWNLOADS_PATH = `
|
||||||
|
mutation SetDownloadsPath($path: String!) {
|
||||||
|
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||||
|
settings { downloadsPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_LOCAL_SOURCE_PATH = `
|
||||||
|
mutation SetLocalSourcePath($path: String!) {
|
||||||
|
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||||
|
settings { localSourcePath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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 UPDATE_EXTENSIONS = `
|
||||||
|
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||||
|
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||||
|
extensions { 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 UPDATE_SOURCE_PREFERENCE = `
|
||||||
|
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
||||||
|
updateSourcePreference(input: { source: $source, change: $change }) {
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOURCE_METAS = `
|
||||||
|
mutation SetSourceMetas($input: SetSourceMetasInput!) {
|
||||||
|
setSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_METAS = `
|
||||||
|
mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) {
|
||||||
|
deleteSourceMetas(input: $input) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_SOURCE_METADATA = `
|
||||||
|
mutation UpdateSourceMetadata(
|
||||||
|
$preUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPreUpdateDeletions: Boolean!
|
||||||
|
$updateInput: SetSourceMetasInput!
|
||||||
|
$hasUpdates: Boolean!
|
||||||
|
$postUpdateDeleteInput: DeleteSourceMetasInput!
|
||||||
|
$hasPostUpdateDeletions: Boolean!
|
||||||
|
$migrateInput: SetSourceMetasInput!
|
||||||
|
$isMigration: Boolean!
|
||||||
|
) {
|
||||||
|
preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) {
|
||||||
|
metas { sourceId key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_SOURCE_META = `
|
||||||
|
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||||
|
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_SOURCE_META = `
|
||||||
|
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
||||||
|
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_CATEGORY_META = `
|
||||||
|
mutation SetCategoryMeta($categoryId: Int!, $key: String!, $value: String!) {
|
||||||
|
setCategoryMeta(input: { meta: { categoryId: $categoryId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_CATEGORY_META = `
|
||||||
|
mutation DeleteCategoryMeta($categoryId: Int!, $key: String!) {
|
||||||
|
deleteCategoryMeta(input: { categoryId: $categoryId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_GLOBAL_META = `
|
||||||
|
mutation SetGlobalMeta($key: String!, $value: String!) {
|
||||||
|
setGlobalMeta(input: { meta: { key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_GLOBAL_META = `
|
||||||
|
mutation DeleteGlobalMeta($key: String!) {
|
||||||
|
deleteGlobalMeta(input: { key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_CACHED_IMAGES = `
|
||||||
|
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
||||||
|
clearCachedImages(input: {
|
||||||
|
cachedPages: $cachedPages
|
||||||
|
cachedThumbnails: $cachedThumbnails
|
||||||
|
downloadedThumbnails: $downloadedThumbnails
|
||||||
|
}) {
|
||||||
|
cachedPages cachedThumbnails downloadedThumbnails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RESET_SETTINGS = `
|
||||||
|
mutation ResetSettings {
|
||||||
|
resetSettings(input: {}) {
|
||||||
|
settings { extensionRepos }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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 UPDATE_MANGAS_CATEGORIES = `
|
||||||
|
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||||
|
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||||
|
mangas { 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 UPDATE_CATEGORIES = `
|
||||||
|
mutation UpdateCategories($ids: [Int!]!, $patch: UpdateCategoryPatchInput!) {
|
||||||
|
updateCategories(input: { ids: $ids, patch: $patch }) {
|
||||||
|
categories { id name order default includeInUpdate includeInDownload }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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_CATEGORY_MANGA = `
|
||||||
|
mutation UpdateCategoryManga($categoryId: Int!) {
|
||||||
|
updateCategoryManga(input: { categoryId: $categoryId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY = `
|
||||||
|
mutation UpdateLibrary {
|
||||||
|
updateLibrary(input: {}) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_LIBRARY_MANGA = `
|
||||||
|
mutation UpdateLibraryManga($mangaId: Int!) {
|
||||||
|
updateLibraryManga(input: { mangaId: $mangaId }) {
|
||||||
|
updateStatus {
|
||||||
|
jobsInfo { isRunning finishedJobs totalJobs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_STOP = `
|
||||||
|
mutation UpdateStop {
|
||||||
|
updateStop(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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SET_MANGA_META = `
|
||||||
|
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||||
|
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_MANGA_META = `
|
||||||
|
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
|
||||||
|
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
|
||||||
|
meta { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Mutations
|
||||||
|
|
||||||
|
## Manga (`mutations/manga.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_MANGA` | `id: Int!` | Fetch and refresh manga metadata from its source |
|
||||||
|
| `UPDATE_MANGA` | `id: Int!`, `inLibrary: Boolean` | Update a single manga's library membership |
|
||||||
|
| `UPDATE_MANGAS` | `ids: [Int!]!`, `inLibrary: Boolean` | Bulk-update library membership for multiple manga |
|
||||||
|
| `UPDATE_MANGA_CATEGORIES` | `mangaId: Int!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Add or remove a single manga from categories |
|
||||||
|
| `UPDATE_MANGAS_CATEGORIES` | `ids: [Int!]!`, `addTo: [Int!]!`, `removeFrom: [Int!]!` | Bulk add/remove multiple manga from categories |
|
||||||
|
| `CREATE_CATEGORY` | `name: String!` | Create a new category |
|
||||||
|
| `UPDATE_CATEGORY` | `id: Int!`, `name: String` | Update a category's name |
|
||||||
|
| `UPDATE_CATEGORIES` | `ids: [Int!]!`, `patch: UpdateCategoryPatchInput!` | Bulk-update multiple categories |
|
||||||
|
| `DELETE_CATEGORY` | `id: Int!` | Delete a category |
|
||||||
|
| `UPDATE_CATEGORY_ORDER` | `id: Int!`, `position: Int!` | Move a category to a new position |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | `categoryId: Int!` | Trigger a metadata update for all manga in a category |
|
||||||
|
| `UPDATE_LIBRARY` | — | Trigger a full library metadata refresh |
|
||||||
|
| `UPDATE_LIBRARY_MANGA` | `mangaId: Int!` | Trigger a metadata update for a single manga |
|
||||||
|
| `UPDATE_STOP` | — | Stop the currently running library update job |
|
||||||
|
| `CREATE_BACKUP` | — | Create a backup and return its download URL |
|
||||||
|
| `RESTORE_BACKUP` | `backup: Upload!` | Restore a backup file and return the restore job status |
|
||||||
|
| `SET_MANGA_META` | `mangaId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a manga |
|
||||||
|
| `DELETE_MANGA_META` | `mangaId: Int!`, `key: String!` | Delete a key/value meta entry from a manga |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`mutations/chapters.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_CHAPTERS` | `mangaId: Int!` | Fetch/refresh the chapter list for a manga from its source |
|
||||||
|
| `FETCH_CHAPTER_PAGES` | `chapterId: Int!` | Fetch the page URLs for a specific chapter |
|
||||||
|
| `MARK_CHAPTER_READ` | `id: Int!`, `isRead: Boolean!` | Mark a single chapter read or unread |
|
||||||
|
| `MARK_CHAPTERS_READ` | `ids: [Int!]!`, `isRead: Boolean!` | Bulk mark chapters read or unread |
|
||||||
|
| `UPDATE_CHAPTERS_PROGRESS` | `ids: [Int!]!`, `isRead: Boolean`, `isBookmarked: Boolean`, `lastPageRead: Int` | Bulk update read state, bookmark state, and last page read |
|
||||||
|
| `DELETE_DOWNLOADED_CHAPTERS` | `ids: [Int!]!` | Delete downloaded chapter files |
|
||||||
|
| `SET_CHAPTER_META` | `chapterId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a chapter |
|
||||||
|
| `DELETE_CHAPTER_META` | `chapterId: Int!`, `key: String!` | Delete a key/value meta entry from a chapter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`mutations/downloads.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `ENQUEUE_DOWNLOAD` | `chapterId: Int!` | Add a single chapter to the download queue |
|
||||||
|
| `ENQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Add multiple chapters to the download queue |
|
||||||
|
| `DEQUEUE_DOWNLOAD` | `chapterId: Int!` | Remove a single chapter from the download queue |
|
||||||
|
| `DEQUEUE_CHAPTERS_DOWNLOAD` | `chapterIds: [Int!]!` | Remove multiple chapters from the download queue |
|
||||||
|
| `REORDER_DOWNLOAD` | `chapterId: Int!`, `to: Int!` | Move a queued chapter to a new position |
|
||||||
|
| `START_DOWNLOADER` | — | Start the downloader |
|
||||||
|
| `STOP_DOWNLOADER` | — | Stop the downloader |
|
||||||
|
| `CLEAR_DOWNLOADER` | — | Clear all items from the download queue |
|
||||||
|
| `FETCH_SOURCE_MANGA` | `source: LongString!`, `type: FetchSourceMangaType!`, `page: Int!`, `query: String`, `filters: [FilterChangeInput!]` | Fetch manga from a source (browse/search) with pagination |
|
||||||
|
| `SET_DOWNLOADS_PATH` | `path: String!` | Set the downloads directory path |
|
||||||
|
| `SET_LOCAL_SOURCE_PATH` | `path: String!` | Set the local source directory path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`mutations/extensions.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `FETCH_EXTENSIONS` | — | Fetch the latest extension list from configured repos |
|
||||||
|
| `UPDATE_EXTENSION` | `id: String!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Install, uninstall, or update a single extension |
|
||||||
|
| `UPDATE_EXTENSIONS` | `ids: [String!]!`, `install: Boolean`, `uninstall: Boolean`, `update: Boolean` | Bulk install, uninstall, or update multiple extensions |
|
||||||
|
| `INSTALL_EXTERNAL_EXTENSION` | `url: String!` | Install an extension from an external APK URL |
|
||||||
|
| `UPDATE_SOURCE_PREFERENCE` | `source: LongString!`, `change: SourcePreferenceChangeInput!` | Update a source-specific preference value |
|
||||||
|
| `SET_SOURCE_META` | `sourceId: LongString!`, `key: String!`, `value: String!` | Set a key/value meta entry on a source |
|
||||||
|
| `DELETE_SOURCE_META` | `sourceId: LongString!`, `key: String!` | Delete a key/value meta entry from a source |
|
||||||
|
| `SET_CATEGORY_META` | `categoryId: Int!`, `key: String!`, `value: String!` | Set a key/value meta entry on a category |
|
||||||
|
| `DELETE_CATEGORY_META` | `categoryId: Int!`, `key: String!` | Delete a key/value meta entry from a category |
|
||||||
|
| `SET_GLOBAL_META` | `key: String!`, `value: String!` | Set a global key/value meta entry |
|
||||||
|
| `DELETE_GLOBAL_META` | `key: String!` | Delete a global key/value meta entry |
|
||||||
|
| `CLEAR_CACHED_IMAGES` | `cachedPages: Boolean`, `cachedThumbnails: Boolean`, `downloadedThumbnails: Boolean` | Selectively clear cached page images, cached thumbnails, or downloaded thumbnails |
|
||||||
|
| `RESET_SETTINGS` | — | Reset all server settings to defaults |
|
||||||
|
| `UPDATE_WEBUI` | — | Trigger a WebUI update and return live status |
|
||||||
|
| `RESET_WEBUI_UPDATE_STATUS` | — | Reset the WebUI update status back to idle |
|
||||||
|
| `SET_EXTENSION_REPOS` | `repos: [String!]!` | Set the list of extension repository URLs |
|
||||||
|
| `SET_SERVER_AUTH` | `authMode: AuthMode!`, `authUsername: String!`, `authPassword: String!` | Configure server auth mode and credentials |
|
||||||
|
| `SET_SOCKS_PROXY` | `socksProxyEnabled: Boolean!`, `socksProxyHost: String!`, `socksProxyPort: String!`, `socksProxyVersion: Int!`, `socksProxyUsername: String!`, `socksProxyPassword: String!` | Configure SOCKS proxy settings |
|
||||||
|
| `SET_FLARESOLVERR` | `flareSolverrEnabled: Boolean!`, `flareSolverrUrl: String!`, `flareSolverrTimeout: Int!`, `flareSolverrSessionName: String!`, `flareSolverrSessionTtl: Int!`, `flareSolverrAsResponseFallback: Boolean!` | Configure FlareSolverr integration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`mutations/tracking.ts`)
|
||||||
|
|
||||||
|
| Mutation | Variables | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `BIND_TRACK` | `mangaId: Int!`, `trackerId: Int!`, `remoteId: LongString!` | Bind a manga to a remote tracker entry |
|
||||||
|
| `UPDATE_TRACK` | `recordId: Int!`, `status: Int`, `lastChapterRead: Float`, `scoreString: String`, `startDate: LongString`, `finishDate: LongString`, `private: Boolean` | Update tracking progress, status, score, and dates |
|
||||||
|
| `UNBIND_TRACK` | `recordId: Int!` | Unbind a manga from a tracker record |
|
||||||
|
| `FETCH_TRACK` | `recordId: Int!` | Refresh a track record from the remote tracker |
|
||||||
|
| `TRACK_PROGRESS` | `mangaId: Int!` | Sync current reading progress to all bound trackers for a manga |
|
||||||
|
| `LOGIN_TRACKER_OAUTH` | `trackerId: Int!`, `callbackUrl: String!` | Initiate OAuth login for a tracker |
|
||||||
|
| `LOGIN_TRACKER_CREDENTIALS` | `trackerId: Int!`, `username: String!`, `password: String!` | Log into a tracker with username and password |
|
||||||
|
| `LOGOUT_TRACKER` | `trackerId: Int!` | Log out of a tracker |
|
||||||
|
| `CONNECT_KOSYNC` | `username: String!`, `password: String!`, `serverAddress: String!` | Connect a KOReader sync account |
|
||||||
|
| `LOGOUT_KOSYNC` | — | Disconnect the KOReader sync account |
|
||||||
|
| `PULL_KOSYNC_PROGRESS` | `chapterId: Int!` | Pull reading progress from KOReader sync for a chapter |
|
||||||
|
| `PUSH_KOSYNC_PROGRESS` | `chapterId: Int!` | Push reading progress to KOReader sync for a chapter |
|
||||||
|
| `LOGIN_USER` | `username: String!`, `password: String!` | Authenticate and return access + refresh tokens |
|
||||||
|
| `REFRESH_TOKEN` | — | Refresh the current access token |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New in Preview
|
||||||
|
|
||||||
|
Mutations now available and not yet wired to any feature in Moku:
|
||||||
|
|
||||||
|
| Mutation | Potential Feature |
|
||||||
|
|----------|-------------------|
|
||||||
|
| `UPDATE_MANGAS_CATEGORIES` | Bulk category editor — move/assign multiple manga at once |
|
||||||
|
| `UPDATE_CATEGORIES` | Bulk category settings — toggle update/download flags for multiple categories at once |
|
||||||
|
| `UPDATE_CATEGORY_MANGA` | Per-category refresh button — update only one category's manga |
|
||||||
|
| `UPDATE_LIBRARY_MANGA` | Single manga refresh — trigger from series detail without a full library update |
|
||||||
|
| `UPDATE_STOP` | Cancel button for library update jobs |
|
||||||
|
| `UPDATE_EXTENSIONS` | Bulk extension updater — "update all" button in extensions page |
|
||||||
|
| `UPDATE_SOURCE_PREFERENCE` | Source settings page — persist source-specific preferences |
|
||||||
|
| `SET_SOURCE_META` / `DELETE_SOURCE_META` | Per-source client state — store browse position, last filter, etc. |
|
||||||
|
| `SET_CATEGORY_META` / `DELETE_CATEGORY_META` | Per-category client state — store sort/filter preferences per category |
|
||||||
|
| `SET_CHAPTER_META` / `DELETE_CHAPTER_META` | Per-chapter client state — annotations, custom notes |
|
||||||
|
| `SET_GLOBAL_META` / `DELETE_GLOBAL_META` | Server-synced app state — replace local persistence for settings that should roam |
|
||||||
|
| `CLEAR_CACHED_IMAGES` | Storage settings — granular cache clearing (pages, thumbnails, downloaded) |
|
||||||
|
| `RESET_SETTINGS` | Settings page — factory reset button |
|
||||||
|
| `UPDATE_WEBUI` / `RESET_WEBUI_UPDATE_STATUS` | WebUI update flow in settings — trigger and monitor update progress |
|
||||||
|
| `TRACK_PROGRESS` | One-tap sync — push current reading position to all trackers without opening tracking panel |
|
||||||
|
| `CONNECT_KOSYNC` / `LOGOUT_KOSYNC` | KOReader sync settings section — connect/disconnect account |
|
||||||
|
| `PULL_KOSYNC_PROGRESS` / `PUSH_KOSYNC_PROGRESS` | KOReader sync — manual pull/push per chapter, or auto-sync on chapter open/close |
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const TRACK_RECORD_FRAGMENT = `
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BIND_TRACK = `
|
||||||
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
|
trackRecord { ${TRACK_RECORD_FRAGMENT} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_TRACK = `
|
||||||
|
mutation UpdateTrack($recordId: Int!, $status: Int, $lastChapterRead: Float, $scoreString: String, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
|
updateTrack(input: { recordId: $recordId, status: $status, lastChapterRead: $lastChapterRead, scoreString: $scoreString, startDate: $startDate, finishDate: $finishDate, private: $private }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UNBIND_TRACK = `
|
||||||
|
mutation UnbindTrack($recordId: Int!) {
|
||||||
|
unbindTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FETCH_TRACK = `
|
||||||
|
mutation FetchTrack($recordId: Int!) {
|
||||||
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TRACK_PROGRESS = `
|
||||||
|
mutation TrackProgress($mangaId: Int!) {
|
||||||
|
trackProgress(input: { mangaId: $mangaId }) {
|
||||||
|
trackRecords {
|
||||||
|
id trackerId lastChapterRead status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||||
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
|
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||||
|
isLoggedIn
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_TRACKER = `
|
||||||
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
|
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CONNECT_KOSYNC = `
|
||||||
|
mutation ConnectKoSync($username: String!, $password: String!, $serverAddress: String!) {
|
||||||
|
connectKoSyncAccount(input: { username: $username, password: $password, serverAddress: $serverAddress }) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGOUT_KOSYNC = `
|
||||||
|
mutation LogoutKoSync {
|
||||||
|
logoutKoSyncAccount(input: {}) {
|
||||||
|
isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PULL_KOSYNC_PROGRESS = `
|
||||||
|
mutation PullKoSyncProgress($chapterId: Int!) {
|
||||||
|
pullKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PUSH_KOSYNC_PROGRESS = `
|
||||||
|
mutation PushKoSyncProgress($chapterId: Int!) {
|
||||||
|
pushKoSyncProgress(input: { chapterId: $chapterId }) {
|
||||||
|
chapter { id lastPageRead isRead }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_USER = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN = `
|
||||||
|
mutation RefreshToken {
|
||||||
|
refreshToken(input: {}) { accessToken }
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const GET_RECENTLY_UPDATED = `
|
||||||
|
query GetRecentlyUpdated {
|
||||||
|
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||||
|
nodes {
|
||||||
|
mangaId
|
||||||
|
fetchedAt
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CHAPTERS = `
|
||||||
|
query GetChapters($mangaId: Int!) {
|
||||||
|
chapters(condition: { mangaId: $mangaId }) {
|
||||||
|
nodes {
|
||||||
|
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||||
|
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
|
query GetDownloadStatus {
|
||||||
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress state tries
|
||||||
|
chapter {
|
||||||
|
id name pageCount mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export const GET_LOCAL_MANGA = `
|
||||||
|
query GetLocalManga {
|
||||||
|
mangas(condition: { sourceId: "0" }) {
|
||||||
|
nodes { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_EXTENSIONS = `
|
||||||
|
query GetExtensions {
|
||||||
|
extensions {
|
||||||
|
nodes {
|
||||||
|
apkName pkgName name lang versionName
|
||||||
|
isInstalled isObsolete hasUpdate iconUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCES = `
|
||||||
|
query GetSources {
|
||||||
|
sources {
|
||||||
|
nodes {
|
||||||
|
id name lang displayName iconUrl isNsfw
|
||||||
|
isConfigurable supportsLatest
|
||||||
|
extension { pkgName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_SOURCE_SETTINGS = `
|
||||||
|
query GetSourceSettings($id: LongString!) {
|
||||||
|
source(id: $id) {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
preferences {
|
||||||
|
... on CheckBoxPreference {
|
||||||
|
type: __typename
|
||||||
|
CheckBoxTitle: title
|
||||||
|
CheckBoxSummary: summary
|
||||||
|
CheckBoxDefault: default
|
||||||
|
CheckBoxCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on SwitchPreference {
|
||||||
|
type: __typename
|
||||||
|
SwitchPreferenceTitle: title
|
||||||
|
SwitchPreferenceSummary: summary
|
||||||
|
SwitchPreferenceDefault: default
|
||||||
|
SwitchPreferenceCurrentValue: currentValue
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on ListPreference {
|
||||||
|
type: __typename
|
||||||
|
ListPreferenceTitle: title
|
||||||
|
ListPreferenceSummary: summary
|
||||||
|
ListPreferenceDefault: default
|
||||||
|
ListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on EditTextPreference {
|
||||||
|
type: __typename
|
||||||
|
EditTextPreferenceTitle: title
|
||||||
|
EditTextPreferenceSummary: summary
|
||||||
|
EditTextPreferenceDefault: default
|
||||||
|
EditTextPreferenceCurrentValue: currentValue
|
||||||
|
dialogTitle
|
||||||
|
dialogMessage
|
||||||
|
key
|
||||||
|
}
|
||||||
|
... on MultiSelectListPreference {
|
||||||
|
type: __typename
|
||||||
|
MultiSelectListPreferenceTitle: title
|
||||||
|
MultiSelectListPreferenceSummary: summary
|
||||||
|
MultiSelectListPreferenceDefault: default
|
||||||
|
MultiSelectListPreferenceCurrentValue: currentValue
|
||||||
|
entries
|
||||||
|
entryValues
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MIGRATABLE_SOURCES = `
|
||||||
|
query GetMigratableSources {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
sourceId
|
||||||
|
source {
|
||||||
|
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./manga";
|
||||||
|
export * from "./chapters";
|
||||||
|
export * from "./downloads";
|
||||||
|
export * from "./extensions";
|
||||||
|
export * from "./tracking";
|
||||||
|
export * from "./updater";
|
||||||
|
export * from "./meta";
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export const GET_LIBRARY = `
|
||||||
|
query GetLibrary {
|
||||||
|
mangas(condition: { inLibrary: true }) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||||
|
description status author artist genre
|
||||||
|
inLibraryAt lastFetchedAt chaptersLastFetchedAt thumbnailUrlLastFetched
|
||||||
|
source { id name displayName }
|
||||||
|
chapters { totalCount }
|
||||||
|
latestFetchedChapter { id uploadDate }
|
||||||
|
latestUploadedChapter { id uploadDate }
|
||||||
|
lastReadChapter { id chapterNumber }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_MANGA = `
|
||||||
|
query GetAllManga {
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA = `
|
||||||
|
query GetManga($id: Int!) {
|
||||||
|
manga(id: $id) {
|
||||||
|
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||||
|
inLibraryAt lastFetchedAt thumbnailUrlLastFetched updateStrategy
|
||||||
|
source { id name displayName }
|
||||||
|
lastReadChapter { id chapterNumber lastPageRead }
|
||||||
|
firstUnreadChapter { id chapterNumber }
|
||||||
|
highestNumberedChapter { id chapterNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CATEGORIES = `
|
||||||
|
query GetCategories {
|
||||||
|
categories {
|
||||||
|
nodes {
|
||||||
|
id name order default includeInUpdate includeInDownload
|
||||||
|
mangas {
|
||||||
|
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||||
|
query GetDownloadedChaptersPages {
|
||||||
|
chapters(condition: { isDownloaded: true }) {
|
||||||
|
nodes { pageCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_DOWNLOADS_PATH = `
|
||||||
|
query GetDownloadsPath {
|
||||||
|
settings { downloadsPath localSourcePath }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LIBRARY_UPDATE_STATUS = `
|
||||||
|
query LibraryUpdateStatus {
|
||||||
|
libraryUpdateStatus {
|
||||||
|
jobsInfo {
|
||||||
|
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||||
|
}
|
||||||
|
mangaUpdates {
|
||||||
|
status
|
||||||
|
manga { id title thumbnailUrl unreadCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_RESTORE_STATUS = `
|
||||||
|
query GetRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name }
|
||||||
|
missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MANGAS_BY_GENRE = `
|
||||||
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
|
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
|
nodes {
|
||||||
|
id title thumbnailUrl inLibrary genre status
|
||||||
|
source { id displayName }
|
||||||
|
}
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const GET_META = `
|
||||||
|
query GetMeta($key: String!) {
|
||||||
|
meta(key: $key) {
|
||||||
|
key value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_METAS = `
|
||||||
|
query GetMetas {
|
||||||
|
metas {
|
||||||
|
nodes { key value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Queries
|
||||||
|
|
||||||
|
## Manga (`queries/manga.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LIBRARY` | — | All in-library manga with metadata, source, chapter counts, download count, unread count, bookmark count, and read progress anchors (`lastReadChapter`, `firstUnreadChapter`) |
|
||||||
|
| `GET_ALL_MANGA` | — | Minimal manga list — id, title, thumbnail, library flag, download count |
|
||||||
|
| `GET_MANGA` | `id: Int!` | Full detail for a single manga — includes `updateStrategy`, `lastReadChapter`, `firstUnreadChapter`, `highestNumberedChapter` |
|
||||||
|
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
||||||
|
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||||
|
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||||
|
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
|
||||||
|
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
||||||
|
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
||||||
|
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chapters (`queries/chapters.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_RECENTLY_UPDATED` | — | Latest 300 chapters ordered by `FETCHED_AT DESC` with parent manga info |
|
||||||
|
| `GET_CHAPTERS` | `mangaId: Int!` | All chapters for a manga — includes `lastReadAt`, `lastPageRead`, read/download/bookmark state, page count, scanlator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads (`queries/downloads.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions (`queries/extensions.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_LOCAL_MANGA` | — | Manga from the local source (`sourceId: "0"`) |
|
||||||
|
| `GET_EXTENSIONS` | — | All extensions — install status, update flag, obsolete flag, metadata |
|
||||||
|
| `GET_SOURCES` | — | All sources — id, name, lang, display name, icon, NSFW flag, `isConfigurable`, `supportsLatest`, `baseUrl` |
|
||||||
|
| `GET_SETTINGS` | — | `extensionRepos` from settings |
|
||||||
|
| `GET_SERVER_SECURITY` | — | Full security config — auth mode, SOCKS proxy settings, FlareSolverr settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking (`queries/tracking.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_TRACKERS` | — | All trackers with login state, token expiry, capability flags (`supportsPrivateTracking`, `supportsReadingDates`, `supportsTrackDeletion`), scores, and statuses |
|
||||||
|
| `GET_MANGA_TRACK_RECORDS` | `mangaId: Int!` | All track records for a specific manga — includes `libraryId`, score, dates, privacy flag |
|
||||||
|
| `SEARCH_TRACKER` | `trackerId: Int!`, `query: String!` | Search a tracker by query string — returns id, title, cover, summary, publishing info |
|
||||||
|
| `GET_ALL_TRACKER_RECORDS` | — | All trackers and their full record lists with associated manga — includes `isTokenExpired`, `libraryId` |
|
||||||
|
| `GET_TRACKER_RECORDS` | `trackerId: Int!` | Records for a specific tracker with associated manga |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updater (`queries/updater.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_ABOUT_SERVER` | — | Server name, version, build type, build time, GitHub and Discord links |
|
||||||
|
| `GET_ABOUT_WEBUI` | — | WebUI channel, tag, and last update timestamp |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | — | Available server updates — channel, tag, download URL |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | — | Available WebUI updates — channel and tag |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | — | Live WebUI update state (`UpdateState` enum), progress percent, and info block |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meta (`queries/meta.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
||||||
|
| `GET_METAS` | — | All global meta entries as a node list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KoSync (`queries/kosync.ts`)
|
||||||
|
|
||||||
|
| Query | Variables | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New in Preview
|
||||||
|
|
||||||
|
Queries and fields now available but not yet wired to any feature in Moku:
|
||||||
|
|
||||||
|
| Query / Field | Potential Feature |
|
||||||
|
|---------------|-------------------|
|
||||||
|
| `GET_ABOUT_SERVER` | About page — server version, build info, links to GitHub and Discord |
|
||||||
|
| `GET_ABOUT_WEBUI` | About page — WebUI version and release channel |
|
||||||
|
| `CHECK_FOR_SERVER_UPDATES` | Update available banner or settings badge |
|
||||||
|
| `CHECK_FOR_WEBUI_UPDATE` | Update available banner or settings badge |
|
||||||
|
| `GET_WEBUI_UPDATE_STATUS` | Update progress indicator in settings |
|
||||||
|
| `GET_META` / `GET_METAS` | Server-side persistence — sync app state across clients without local storage |
|
||||||
|
| `GET_KOSYNC_STATUS` | KOReader sync settings section — show connection state |
|
||||||
|
| `trackRecords` (top-level) | Flat tracker record browser — filter by score, privacy, tracker |
|
||||||
|
| `category` (single by id) | Direct category detail without fetching all categories |
|
||||||
|
| `chapter` (single by id) | Direct chapter lookup without fetching full manga chapter list |
|
||||||
|
| `source` (single by id) | Source detail page — preferences, filters, browse |
|
||||||
|
| `tracker` (single by id) | Individual tracker detail — statuses, records |
|
||||||
|
| `trackRecord` (single by id) | Direct track record lookup for deep linking |
|
||||||
|
| `lastUpdateTimestamp` | Stale data detection — poll before refetching library |
|
||||||
|
| `MangaType.hasDuplicateChapters` | Library health view — flag manga with duplicate chapter numbers |
|
||||||
|
| `MangaType.age` / `chaptersAge` | Stale manga indicator — highlight series with no updates in N days |
|
||||||
|
| `MangaType.initialized` | Loading skeleton gating — skip detail render until manga is fully fetched |
|
||||||
|
| `SourceType.isConfigurable` | Source list — show gear icon only when source is configurable |
|
||||||
|
| `SourceType.supportsLatest` | Source browse UI — conditionally show Latest tab |
|
||||||
|
| `TrackerType.supportsTrackDeletion` | Tracking panel — show remove button only when tracker supports it |
|
||||||
|
| `TrackerType.supportsReadingDates` | Tracking panel — show date fields only when tracker supports them |
|
||||||
|
| `TrackerType.isTokenExpired` | Re-auth prompt — detect expired tokens before a request fails |
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export const GET_TRACKERS = `
|
||||||
|
query GetTrackers {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn isTokenExpired authUrl
|
||||||
|
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||||
|
scores
|
||||||
|
statuses { value name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
|
manga(id: $mangaId) {
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SEARCH_TRACKER = `
|
||||||
|
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||||
|
searchTracker(input: { trackerId: $trackerId, query: $query }) {
|
||||||
|
trackSearches {
|
||||||
|
id trackerId remoteId title coverUrl summary
|
||||||
|
publishingStatus publishingType startDate totalChapters trackingUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ALL_TRACKER_RECORDS = `
|
||||||
|
query GetAllTrackerRecords {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn isTokenExpired scores
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId title status displayScore lastChapterRead
|
||||||
|
totalChapters remoteUrl private libraryId
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_TRACKER_RECORDS = `
|
||||||
|
query GetTrackerRecords($trackerId: Int!) {
|
||||||
|
trackers(condition: { id: $trackerId }) {
|
||||||
|
nodes {
|
||||||
|
id name
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id title status displayScore lastChapterRead totalChapters remoteUrl
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const GET_ABOUT_SERVER = `
|
||||||
|
query GetAboutServer {
|
||||||
|
aboutServer {
|
||||||
|
name version buildType buildTime github discord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_ABOUT_WEBUI = `
|
||||||
|
query GetAboutWebUI {
|
||||||
|
aboutWebUI {
|
||||||
|
channel tag updateTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CHECK_FOR_SERVER_UPDATES = `
|
||||||
|
query CheckForServerUpdates {
|
||||||
|
checkForServerUpdates {
|
||||||
|
channel tag url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |