Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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"
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
download_suwayomi() {
|
||||
local asset="$1" sha="$2" outdir="$3"
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/${asset}" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/${asset}" \
|
||||
-o "${outdir}.tar.gz"
|
||||
echo "${sha} ${outdir}.tar.gz" | shasum -a 256 -c -
|
||||
mkdir -p "${outdir}"
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
}
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-arm64.tar.gz" \
|
||||
"c80abdbba29f7895e9556c6c9481368557d5f930b5f69bcb30639ba498925f3c" \
|
||||
"Suwayomi-Server-v2.1.2087-macOS-arm64.tar.gz" \
|
||||
"59f73a53a139d5d843e16cab4f3ac425a410add6bee0a60920fa26eb0a4b8a5c" \
|
||||
"suwayomi-arm64"
|
||||
|
||||
download_suwayomi \
|
||||
"Suwayomi-Server-v2.1.1867-macOS-x64.tar.gz" \
|
||||
"c7590aeb645dd7135a05b9f3ea1fee384a4abeb465c0b3638d5b738d20dfe174" \
|
||||
"Suwayomi-Server-v2.1.2087-macOS-x64.tar.gz" \
|
||||
"da7e664e4c2615a0b9eac09ee38fe979feee1d6c0b266e19dba1ceea8ae3795c" \
|
||||
"suwayomi-x64"
|
||||
|
||||
- name: Stage Suwayomi sidecars
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
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/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v'"$VERSION"'") | .id' | head -1)
|
||||
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
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
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/Youwes09/Moku/releases/$RELEASE_ID/assets?name=$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)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 0.4.0)"
|
||||
description: "Version to build (e.g. 0.9.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
@@ -79,9 +79,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-windows-x64.zip" \
|
||||
"https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087-windows-x64.zip" \
|
||||
-o suwayomi-windows.zip
|
||||
echo "ab6687d278e0dd0984f67abbc853511a7e764f84b126a35d09bfd9b0307321ff suwayomi-windows.zip" | sha256sum -c -
|
||||
echo "65c3ec544190bc4e52f8ba05b49c87448421d9825aaaeb902cb4e34e69ff7207 suwayomi-windows.zip" | sha256sum -c -
|
||||
unzip -q suwayomi-windows.zip -d suwayomi-raw
|
||||
|
||||
- name: Extract Suwayomi bundle
|
||||
@@ -134,12 +134,15 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases" | jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||
RELEASE_ID=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/moku-project/Moku/releases" \
|
||||
| jq -r '.[] | select(.tag_name == "v${{ github.event.inputs.version }}" and .draft == true) | .id')
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "Deleting existing draft release $RELEASE_ID"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/releases/$RELEASE_ID"
|
||||
# Also delete the tag so tauri-action can recreate it
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/Youwes09/Moku/git/refs/tags/v${{ github.event.inputs.version }}"
|
||||
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"
|
||||
@@ -149,8 +152,6 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: v${{ github.event.inputs.version }}
|
||||
releaseName: Moku v${{ github.event.inputs.version }}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pkgname=moku
|
||||
pkgver=0.5.0
|
||||
pkgver=0.9.3
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Youwes09/Moku"
|
||||
license=('Apache 2.0')
|
||||
url="https://github.com/moku-project/Moku"
|
||||
license=('Apache-2.0')
|
||||
depends=(
|
||||
'webkit2gtk-4.1'
|
||||
'gtk3'
|
||||
@@ -18,13 +18,13 @@ makedepends=(
|
||||
'pnpm'
|
||||
)
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/Youwes09/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"suwayomi-server.jar::https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar"
|
||||
"jdk.tar.gz::https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz"
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||
)
|
||||
sha256sums=(
|
||||
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||
)
|
||||
sha256sums=('2475d4bb4c7e8527384f7fcf9b0ace1c8a6354416f3af31398b844e35953fb73'
|
||||
'51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af'
|
||||
'f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d')
|
||||
|
||||
prepare() {
|
||||
cd "Moku-$pkgver"
|
||||
@@ -34,7 +34,6 @@ prepare() {
|
||||
build() {
|
||||
cd "Moku-$pkgver"
|
||||
pnpm build
|
||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -46,17 +45,14 @@ package() {
|
||||
install -Dm755 src-tauri/target/release/moku \
|
||||
"$pkgdir/usr/bin/moku"
|
||||
|
||||
install -dm755 "$pkgdir/usr/lib/moku/jre"
|
||||
tar -xf "$srcdir/jdk.tar.gz" -C "$pkgdir/usr/lib/moku/jre" --strip-components=1
|
||||
|
||||
install -Dm644 "$srcdir/suwayomi-server.jar" \
|
||||
install -Dm644 "$srcdir/Suwayomi-Server-v2.1.2087.jar" \
|
||||
"$pkgdir/usr/lib/moku/tachidesk/Suwayomi-Server.jar"
|
||||
|
||||
install -dm755 "$pkgdir/usr/lib/moku/tachidesk/default-conf"
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'EOF'
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -64,11 +60,11 @@ server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
CONF
|
||||
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/tachidesk-server" << 'EOF'
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/moku-suwayomi" << 'LAUNCHER'
|
||||
#!/bin/sh
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
@@ -90,25 +86,25 @@ unset WAYLAND_DISPLAY
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
exec /usr/lib/moku/jre/bin/java \
|
||||
exec java \
|
||||
-Djava.awt.headless=true \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
-Dsun.awt.disablegui=true \
|
||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||
EOF
|
||||
LAUNCHER
|
||||
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.Youwes09.Moku.app.desktop"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||
"$pkgdir/usr/share/applications/io.github.moku_project.Moku.desktop"
|
||||
install -Dm644 src-tauri/icons/32x32.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 src-tauri/icons/128x128.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.app.png"
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 src-tauri/icons/128x128@2x.png \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.app.png"
|
||||
install -Dm644 packaging/io.github.Youwes09.Moku.app.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.Youwes09.Moku.metainfo.xml"
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||
[](./LICENSE)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||

|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -20,16 +20,20 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots">View all screenshots →</a>
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -43,7 +47,6 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
@@ -54,36 +57,55 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
||||
|
||||
## Installation
|
||||
|
||||
### Flatpak (Linux, recommended)
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### Windows
|
||||
|
||||
**winget:**
|
||||
|
||||
```powershell
|
||||
winget install Moku.Moku
|
||||
```
|
||||
|
||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||
|
||||
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### Linux (Flatpak, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
flatpak run dev.moku.app
|
||||
flatpak install io.github.moku_app.Moku
|
||||
```
|
||||
|
||||
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
```
|
||||
|
||||
### Nix
|
||||
|
||||
```bash
|
||||
nix run github:Youwes09/Moku
|
||||
nix run github:moku-project/Moku
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/Moku";
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
@@ -105,7 +127,7 @@ You can point Moku at any Suwayomi instance — local or remote — via **Settin
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Youwes09/Moku
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
@@ -136,7 +158,7 @@ pnpm tauri:dev
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ Major Revisions:
|
||||
|
||||
Minor Revisions:
|
||||
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||
- Revise Migration (https://github.com/Suwayomi/Suwayomi-WebUI/pull/1073)
|
||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||
|
||||
|
||||
Priority Bugs:
|
||||
- Fix Library-Refresh System (TESTING)
|
||||
@@ -19,21 +16,24 @@ Priority Bugs:
|
||||
- Allow User to Wipe Suwayomi (Scratch)
|
||||
- If Possible, Component based Wipe (Library, Etc)
|
||||
|
||||
|
||||
In-Progress:
|
||||
Pending/On-Hold:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
- Working on 3D Display Cards
|
||||
- Add Flathub Support (Pending Video)
|
||||
|
||||
- QOL Animations & Revamps
|
||||
- Tracking Revamp
|
||||
- Completely Revamp Tracking
|
||||
|
||||
- Change Auto-Link Threshold
|
||||
- Fix Auto-Link De-dupe for Images
|
||||
- Optimize Auto-Link Latency (IP)
|
||||
|
||||
In-Progress:
|
||||
- Fix Tracking Login
|
||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
||||
|
||||
- Apply Syer's Fix for Library on Backup Load (Manga Metadata)
|
||||
- Note User's have to always install extensions manually
|
||||
- Create "Missing Source" for Manga
|
||||
|
||||
- Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR)
|
||||
|
||||
- UI LOGIN DOES NOT WORK OFFLINE
|
||||
Notes from last time:
|
||||
- Currently working on #42, just need to mount panel and fix button in reader
|
||||
|
Before Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 940 KiB |
@@ -14,11 +14,15 @@
|
||||
outputs =
|
||||
inputs@{ flake-parts, crane, rust-overlay, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
|
||||
perSystem = { system, lib, ... }:
|
||||
perSystem =
|
||||
{ system, lib, ... }:
|
||||
let
|
||||
version = "0.9.0";
|
||||
version = "0.9.3";
|
||||
|
||||
pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
@@ -26,7 +30,10 @@
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-analyzer"
|
||||
];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
@@ -48,8 +55,10 @@
|
||||
|
||||
frontendSrc = lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter = path: type:
|
||||
let base = builtins.baseNameOf path;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
base = builtins.baseNameOf path;
|
||||
in
|
||||
(lib.hasInfix "/src" path)
|
||||
|| base == "index.html"
|
||||
@@ -59,234 +68,44 @@
|
||||
|| base == "vite.config.ts";
|
||||
};
|
||||
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "moku-frontend";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
|
||||
nativeBuildInputs = with pkgs; [ nodejs_22 pnpm pnpmConfigHook ];
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
pname = "moku-frontend";
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
fetcherVersion = 1;
|
||||
hash = "sha256-nlhm3NYn4x+JlKcCgj1lAX43muB3QRKGDzaxfQNfJwc=";
|
||||
};
|
||||
|
||||
buildPhase = "pnpm build";
|
||||
installPhase = "cp -r dist $out";
|
||||
};
|
||||
|
||||
cargoSrc = lib.cleanSourceWith {
|
||||
src = ./src-tauri;
|
||||
filter = path: type:
|
||||
filter =
|
||||
path: type:
|
||||
(craneLib.filterCargoSources path type)
|
||||
|| (lib.hasInfix "/icons/" path)
|
||||
|| (lib.hasInfix "/capabilities/" path)
|
||||
|| (builtins.baseNameOf path == "tauri.conf.json");
|
||||
};
|
||||
|
||||
commonArgs = {
|
||||
src = cargoSrc;
|
||||
cargoToml = ./src-tauri/Cargo.toml;
|
||||
cargoLock = ./src-tauri/Cargo.lock;
|
||||
strictDeps = true;
|
||||
buildInputs = runtimeLibs;
|
||||
nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ];
|
||||
preBuild = ''
|
||||
cp -r ${frontend} ../dist
|
||||
'';
|
||||
suwayomiServer = pkgs.callPackage ./nix/server.nix { };
|
||||
|
||||
frontend = pkgs.callPackage ./nix/frontend.nix {
|
||||
inherit version;
|
||||
src = frontendSrc;
|
||||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
moku = craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
meta.mainProgram = "moku";
|
||||
postInstall = ''
|
||||
mkdir -p "$out/share/applications"
|
||||
cat > "$out/share/applications/moku.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Moku
|
||||
Comment=Manga reader frontend for Suwayomi
|
||||
Exec=$out/bin/moku
|
||||
Icon=moku
|
||||
Terminal=false
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comic;reader;suwayomi;
|
||||
StartupWMClass=moku
|
||||
EOF
|
||||
|
||||
for size in 32x32 128x128 256x256 512x512; do
|
||||
src="icons/$size.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/$size/apps/moku.png"
|
||||
done
|
||||
|
||||
for size in 128x128 256x256; do
|
||||
src="icons/''${size}@2x.png"
|
||||
[ -f "$src" ] && install -Dm644 "$src" \
|
||||
"$out/share/icons/hicolor/''${size}@2/apps/moku.png"
|
||||
done
|
||||
|
||||
install -Dm644 "${./src/assets/moku-icon.svg}" \
|
||||
"$out/share/icons/hicolor/scalable/apps/moku.svg"
|
||||
|
||||
wrapProgram $out/bin/moku \
|
||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||
pkgs.gsettings-desktop-schemas
|
||||
pkgs.gtk3
|
||||
]}" \
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
|
||||
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
|
||||
--set GDK_BACKEND wayland \
|
||||
--set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS 1
|
||||
'';
|
||||
});
|
||||
|
||||
bumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-bump";
|
||||
runtimeInputs = with pkgs; [ gnused coreutils git rustToolchain ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
(cd "$REPO/src-tauri" && cargo generate-lockfile)
|
||||
echo "Bumped to $VERSION"
|
||||
'';
|
||||
moku = import ./nix/moku.nix {
|
||||
inherit lib craneLib pkgs runtimeLibs frontend suwayomiServer version cargoSrc;
|
||||
appIcon = ./src/assets/moku-icon.svg;
|
||||
};
|
||||
|
||||
flatpakScript = pkgs.writeShellApplication {
|
||||
name = "moku-flatpak";
|
||||
runtimeInputs = with pkgs; [
|
||||
gnused coreutils git
|
||||
nodejs_22 pnpm
|
||||
appstream flatpak-builder flatpak
|
||||
(python3.withPackages (ps: [ ps.aiohttp ps.tomlkit ]))
|
||||
];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#flatpak -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
MANIFEST="$REPO/io.github.Youwes09.Moku.yml"
|
||||
|
||||
echo "── Bumping versions ──"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/tauri.conf.json"
|
||||
sed -i "0,/^version = \"[^\"]*\"/s//version = \"$VERSION\"/" \
|
||||
"$REPO/src-tauri/Cargo.toml"
|
||||
sed -i "s/version = \"[^\"]*\";/version = \"$VERSION\";/g" \
|
||||
"$REPO/flake.nix"
|
||||
echo "Done"
|
||||
|
||||
echo "── Building frontend ──"
|
||||
cd "$REPO"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
echo "Done"
|
||||
|
||||
echo "── Repacking frontend-dist.tar.gz ──"
|
||||
tar -czf "$REPO/packaging/frontend-dist.tar.gz" -C "$REPO" dist
|
||||
FRONTEND_SHA=$(sha256sum "$REPO/packaging/frontend-dist.tar.gz" | awk '{print $1}')
|
||||
echo "sha256: $FRONTEND_SHA"
|
||||
|
||||
echo "── Patching manifest sha256 ──"
|
||||
python3 - "$MANIFEST" "$FRONTEND_SHA" <<'PYEOF'
|
||||
import re, sys
|
||||
path, sha = sys.argv[1], sys.argv[2]
|
||||
text = open(path).read()
|
||||
updated, n = re.subn(
|
||||
r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+',
|
||||
r'\g<1>' + sha, text)
|
||||
if n == 0:
|
||||
sys.exit("ERROR: could not find frontend-dist sha256 in manifest")
|
||||
open(path, 'w').write(updated)
|
||||
PYEOF
|
||||
echo "Done"
|
||||
|
||||
echo "── Regenerating cargo-sources.json ──"
|
||||
python3 "$REPO/packaging/flatpak-cargo-generator.py" \
|
||||
"$REPO/src-tauri/Cargo.lock" \
|
||||
-o "$REPO/packaging/cargo-sources.json"
|
||||
echo "Done"
|
||||
|
||||
echo "── Building flatpak ──"
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
flatpak-builder \
|
||||
--repo="$REPO/repo" \
|
||||
--force-clean \
|
||||
"$REPO/build-dir" \
|
||||
"$MANIFEST"
|
||||
flatpak build-bundle "$REPO/repo" "$REPO/moku.flatpak" io.github.Youwes09.Moku
|
||||
rm -rf "$REPO/build-dir" "$REPO/repo"
|
||||
echo "moku.flatpak created"
|
||||
|
||||
echo ""
|
||||
echo "Done — v$VERSION"
|
||||
echo " -> $REPO/moku.flatpak"
|
||||
echo ""
|
||||
echo "After pushing the tag, run:"
|
||||
echo " nix run .#pkgbuild-bump -- $VERSION"
|
||||
'';
|
||||
};
|
||||
|
||||
pkgbuildBumpScript = pkgs.writeShellApplication {
|
||||
name = "moku-pkgbuild-bump";
|
||||
runtimeInputs = with pkgs; [ gnused curl coreutils git ];
|
||||
text = ''
|
||||
[[ $# -lt 1 ]] && { echo "Usage: nix run .#pkgbuild-bump -- <version>"; exit 1; }
|
||||
VERSION="$1"
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
PKGBUILD="$REPO/PKGBUILD"
|
||||
[[ -f "$PKGBUILD" ]] || { echo "PKGBUILD not found"; exit 1; }
|
||||
|
||||
TARBALL_URL="https://github.com/Youwes09/Moku/archive/refs/tags/v$VERSION.tar.gz"
|
||||
echo "Fetching tarball sha256..."
|
||||
TARBALL_SHA=$(curl -fsSL "$TARBALL_URL" | sha256sum | awk '{print $1}')
|
||||
|
||||
sed -i "s/^pkgver=.*/pkgver=$VERSION/" "$PKGBUILD"
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD"
|
||||
sed -i "s/\(sha256sums=('\)[0-9a-f]\{64\}/\1$TARBALL_SHA/" "$PKGBUILD"
|
||||
|
||||
grep -q "$TARBALL_SHA" "$PKGBUILD" \
|
||||
|| { echo "ERROR: sha256 replacement failed"; exit 1; }
|
||||
|
||||
echo "PKGBUILD -> $VERSION ($TARBALL_SHA)"
|
||||
'';
|
||||
};
|
||||
|
||||
tunnelScript = pkgs.writeShellApplication {
|
||||
name = "moku-tunnel";
|
||||
runtimeInputs = with pkgs; [ cloudflared ];
|
||||
text = ''
|
||||
PORT="''${1:-4567}"
|
||||
cloudflared tunnel --url "http://localhost:$PORT"
|
||||
'';
|
||||
};
|
||||
scripts = import ./nix/scripts.nix { inherit pkgs rustToolchain version; };
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit moku frontend suwayomiServer;
|
||||
default = moku;
|
||||
};
|
||||
|
||||
apps = {
|
||||
default = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
moku = { type = "app"; program = "${moku}/bin/moku"; };
|
||||
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
|
||||
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
|
||||
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
|
||||
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
|
||||
};
|
||||
|
||||
packages = {
|
||||
inherit moku frontend;
|
||||
default = 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 {
|
||||
@@ -297,22 +116,27 @@ EOF
|
||||
wrapGAppsHook3
|
||||
nodejs_22
|
||||
pnpm
|
||||
suwayomi-server
|
||||
suwayomiServer
|
||||
cloudflared
|
||||
xdg-utils
|
||||
(python3.withPackages (ps: [
|
||||
ps.aiohttp
|
||||
ps.tomlkit
|
||||
]))
|
||||
];
|
||||
shellHook = ''
|
||||
export NO_STRIP=true
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
echo "Moku dev shell — pnpm install && pnpm tauri:dev"
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " nix run .#bump -- <ver> bump versions only"
|
||||
echo " nix run .#flatpak -- <ver> full flatpak build"
|
||||
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
|
||||
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
|
||||
echo " nix run .#bump -- <ver>"
|
||||
echo " git commit && git tag && git push"
|
||||
echo " nix run .#post-tag-bump -- <ver>"
|
||||
echo " nix run .#flatpak -- <ver>"
|
||||
echo " nix run .#tunnel -- [port]"
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app-id: io.github.Youwes09.Moku
|
||||
app-id: io.github.moku_project.Moku
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
@@ -95,12 +95,12 @@ modules:
|
||||
cat > /app/tachidesk/default-conf/server.conf << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "stable"
|
||||
server.webUIChannel = "PREVIEW"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -114,7 +114,7 @@ modules:
|
||||
cat > /app/bin/tachidesk-server << 'EOF'
|
||||
#!/bin/sh
|
||||
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/moku/tachidesk"
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Seed conf on first run
|
||||
@@ -130,7 +130,7 @@ modules:
|
||||
"$DATA_DIR/server.conf"
|
||||
|
||||
# Append keys if absent
|
||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
|
||||
@@ -155,8 +155,8 @@ modules:
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar
|
||||
sha256: f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3
|
||||
dest-filename: Suwayomi-Server.jar
|
||||
|
||||
- name: moku
|
||||
@@ -171,19 +171,19 @@ modules:
|
||||
- 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
|
||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.desktop /app/share/applications/io.github.Youwes09.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.Youwes09.Moku.png
|
||||
- install -Dm644 packaging/io.github.Youwes09.Moku.metainfo.xml /app/share/metainfo/io.github.Youwes09.Moku.metainfo.xml
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png
|
||||
- install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml /app/share/metainfo/io.github.moku_project.Moku.metainfo.xml
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/Youwes09/Moku.git
|
||||
tag: v0.8.0
|
||||
commit: c573c543187cbd1ca1455b25d6bce0fc62666341
|
||||
url: https://github.com/moku-project/Moku.git
|
||||
tag: v0.9.3
|
||||
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: d547893e1b76f1678df131d46b0964e9ef34e54e8571d5c435a22cef7316f75a
|
||||
sha256: c690eb3cb24e89fec3f4e92f7a4a82d9a465b58f6680a332c1e44f1361ac96af
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
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-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
})
|
||||
@@ -10,22 +10,23 @@
|
||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.8",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "~2.4.2",
|
||||
"clsx": "^2.1.1",
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"svelte-spa-router": "^5.1.0",
|
||||
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc",
|
||||
"tauri-plugin-drpc": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4.4.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=io.github.Youwes09.Moku
|
||||
Icon=io.github.moku_project.Moku
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.Youwes09.Moku</id>
|
||||
<id>io.github.moku_project.Moku</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
@@ -19,30 +19,30 @@
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.Youwes09.Moku.desktop</launchable>
|
||||
<launchable type="desktop-id">io.github.moku_project.Moku.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/Youwes09/Moku</url>
|
||||
<url type="bugtracker">https://github.com/Youwes09/Moku/issues</url>
|
||||
<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/Youwes09/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Home.png</image>
|
||||
<caption>Home screen showing your manga library</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Reader.png</image>
|
||||
<caption>Built-in manga reader</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Discover.png</image>
|
||||
<caption>Discover new manga across hundreds of sources</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Downloads.png</image>
|
||||
<caption>Download manager</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/Youwes09/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||
<image>https://raw.githubusercontent.com/moku-project/Moku/main/docs/screenshots/Moku-Settings.png</image>
|
||||
<caption>Settings</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
@@ -54,11 +54,16 @@
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.8.0" date="2025-04-01">
|
||||
<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>
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moku"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -15,12 +15,13 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = [] }
|
||||
tauri = { version = "2.0", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-discord-rpc = { git = "https://github.com/Youwes09/tauri-plugin-discord-rpc" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -31,6 +32,12 @@ urlencoding = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Security_Credentials_UI",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# — Suwayomi launcher for Linux AppImage/deb.
|
||||
# Tauri resolves this via resolve_server_binary() in lib.rs, which looks for
|
||||
# "suwayomi-launcher" or "suwayomi-launcher.sh" in the resource directory.
|
||||
set -e
|
||||
|
||||
# ── Locate our resource directory ─────────────────────────────────────────────
|
||||
# In an AppImage: resources sit at <mountpoint>/resources/
|
||||
# In a deb install: /usr/lib/moku/resources/ (Tauri's default)
|
||||
# We resolve relative to this script's own location.
|
||||
SELF="$0"
|
||||
while [ -L "$SELF" ]; do
|
||||
SELF="$(readlink "$SELF")"
|
||||
done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
||||
|
||||
# Tauri places resources one level up from the binary on Linux.
|
||||
# Try a few candidates so this works in both AppImage and installed layouts.
|
||||
find_resource() {
|
||||
for candidate in \
|
||||
"${SCRIPT_DIR}" \
|
||||
"${SCRIPT_DIR}/../resources" \
|
||||
"${SCRIPT_DIR}/resources"
|
||||
do
|
||||
if [ -f "${candidate}/Suwayomi-Server.jar" ]; then
|
||||
echo "$(cd "$candidate" && pwd)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
RESOURCE_DIR=$(find_resource) || {
|
||||
echo "[launcher] ERROR: cannot locate Suwayomi-Server.jar relative to $SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
JAR="${RESOURCE_DIR}/Suwayomi-Server.jar"
|
||||
JAVA="${RESOURCE_DIR}/jre/bin/java"
|
||||
CATCH_ABORT="${RESOURCE_DIR}/catch_abort.so"
|
||||
|
||||
echo "[launcher] RESOURCE_DIR=$RESOURCE_DIR" >&2
|
||||
echo "[launcher] JAVA=$JAVA" >&2
|
||||
echo "[launcher] JAR=$JAR" >&2
|
||||
|
||||
if [ ! -x "$JAVA" ]; then
|
||||
echo "[launcher] ERROR: java not executable at $JAVA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$JAR" ]; then
|
||||
echo "[launcher] ERROR: jar not found at $JAR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Data directory ─────────────────────────────────────────────────────────────
|
||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# ── Seed server.conf on first run ──────────────────────────────────────────────
|
||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||
cat > "$DATA_DIR/server.conf" << 'EOF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "PREVIEW"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Force-patch the three keys that cause JCEF/GUI crashes ────────────────────
|
||||
sed -i \
|
||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = true|' \
|
||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||
"$DATA_DIR/server.conf"
|
||||
|
||||
# Append keys if absent (e.g. user-managed conf missing them)
|
||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||
|
||||
# ── Suppress any GUI environment that would confuse the JVM ───────────────────
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
|
||||
export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||
|
||||
# ── LD_PRELOAD catch_abort.so if present ──────────────────────────────────────
|
||||
# Catches SIGTRAP/SIGILL from KCEF/Webview so a bad extension can't
|
||||
# bring down the whole server process (mirrors the Flatpak build).
|
||||
if [ -f "$CATCH_ABORT" ]; then
|
||||
export LD_PRELOAD="${CATCH_ABORT}${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
fi
|
||||
|
||||
exec "$JAVA" \
|
||||
-Djava.awt.headless=true \
|
||||
-Dapple.awt.UIElement=true \
|
||||
-Dsun.java2d.noddraw=true \
|
||||
-Dsun.awt.disablegui=true \
|
||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||
-jar "$JAR"
|
||||
@@ -5,6 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:tray:default",
|
||||
"core:app:allow-default-window-icon",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"shell:allow-open",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-spawn",
|
||||
@@ -30,6 +34,7 @@
|
||||
"process:allow-restart",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"store:default",
|
||||
"discord-rpc:default",
|
||||
"discord-rpc:allow-connect",
|
||||
"discord-rpc:allow-disconnect",
|
||||
|
||||
@@ -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,102 @@
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||
if cache_dir.exists() {
|
||||
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
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", "bin/kcef", "cache/kcef"] {
|
||||
let p = data_dir.join(dir);
|
||||
if p.exists() {
|
||||
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
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);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
|
||||
let 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,810 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
mod commands;
|
||||
mod server;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::io::Write;
|
||||
use sysinfo::Disks;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
|
||||
struct ServerState(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageInfo {
|
||||
manga_bytes: u64,
|
||||
total_bytes: u64,
|
||||
free_bytes: u64,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "kind", content = "message")]
|
||||
pub enum SpawnError {
|
||||
NotConfigured(String),
|
||||
SpawnFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ReleaseInfo {
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub published_at: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
struct UpdateProgress {
|
||||
downloaded: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
fn strip_unc(path: PathBuf) -> PathBuf {
|
||||
let s = path.to_string_lossy();
|
||||
if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path.trim());
|
||||
}
|
||||
suwayomi_data_dir().join("downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
let path = resolve_downloads_path(&downloads_path);
|
||||
|
||||
let manga_bytes = if path.exists() {
|
||||
WalkDir::new(&path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let disk = disks
|
||||
.iter()
|
||||
.filter(|d| stat_path.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.ok_or_else(|| "Could not find disk for path".to_string())?;
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes: disk.total_space(),
|
||||
free_bytes: disk.available_space(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_default_downloads_path() -> String {
|
||||
resolve_downloads_path("").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_path_exists(path: String) -> bool {
|
||||
std::path::Path::new(path.trim()).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path.trim()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn migrate_downloads(app: tauri::AppHandle, src: String, dst: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
use std::fs;
|
||||
|
||||
let src_path = std::path::PathBuf::from(src.trim());
|
||||
let dst_path = std::path::PathBuf::from(dst.trim());
|
||||
|
||||
if !src_path.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total: u64 = WalkDir::new(&src_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.count() as u64;
|
||||
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({ "done": 0u64, "total": total, "current": "" }));
|
||||
|
||||
let mut done: u64 = 0;
|
||||
|
||||
for entry in WalkDir::new(&src_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel = entry.path().strip_prefix(&src_path).map_err(|e| e.to_string())?;
|
||||
let target = dst_path.join(rel);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(entry.path(), &target).map_err(|e| e.to_string())?;
|
||||
done += 1;
|
||||
let _ = app.emit("migrate_progress", serde_json::json!({
|
||||
"done": done, "total": total, "current": rel.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn kill_tachidesk(app: &tauri::AppHandle) {
|
||||
let state = app.state::<ServerState>();
|
||||
if let Some(child) = state.0.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.status();
|
||||
|
||||
for _ in 0..30 {
|
||||
let still_running = std::process::Command::new("tasklist")
|
||||
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !still_running { break; }
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = std::process::Command::new("pkill").args(["-f", "tachidesk"]).status();
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.webUIInterface = "browser"
|
||||
server.webUIFlavor = "WebUI"
|
||||
server.webUIChannel = "stable"
|
||||
server.electronPath = ""
|
||||
server.debugLogsEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
server.autoDownloadNewChapters = false
|
||||
server.globalUpdateInterval = 12
|
||||
server.maxSourcesInParallel = 6
|
||||
server.extensionRepos = []
|
||||
"#;
|
||||
|
||||
fn seed_server_conf(data_dir: &PathBuf) {
|
||||
let conf_path = data_dir.join("server.conf");
|
||||
|
||||
if !conf_path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||
return;
|
||||
}
|
||||
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", "false"),
|
||||
"server.initialOpenInBrowserEnabled", "false",
|
||||
),
|
||||
"server.systemTrayEnabled", "false",
|
||||
);
|
||||
|
||||
let _ = std::fs::write(&conf_path, patched);
|
||||
}
|
||||
|
||||
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
|
||||
let replacement = format!("{key} = {value}");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
|
||||
let mut out = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
out.push('\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = text;
|
||||
if !out.ends_with('\n') { out.push('\n'); }
|
||||
out.push_str(&replacement);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn suwayomi_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
|
||||
.join("moku\\tachidesk")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
|
||||
.join("io.github.Youwes09.Moku.app/tachidesk")
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
|
||||
base.join("moku/tachidesk")
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerInvocation {
|
||||
bin: String,
|
||||
args: Vec<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let java = strip_unc(bundle_dir.join("jre").join("bin").join("java.exe"));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||
|
||||
do_log(log, &format!("[find_java] path: {:?} exists: {}", java, java.exists()));
|
||||
if java.exists() { Some(java) } else { None }
|
||||
}
|
||||
|
||||
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
|
||||
eprintln!("{}", msg);
|
||||
if let Some(f) = log {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_server_binary(
|
||||
binary: &str,
|
||||
app: &tauri::AppHandle,
|
||||
log: &mut Option<std::fs::File>,
|
||||
) -> Result<ServerInvocation, SpawnError> {
|
||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
||||
|
||||
if !binary.trim().is_empty() {
|
||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
|
||||
if path.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: path.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
||||
});
|
||||
}
|
||||
do_log(log, "[resolve] user path not found, falling through");
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||
let p = bin_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sibling: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(bin_dir.to_path_buf()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir: Some(bundle_dir),
|
||||
});
|
||||
}
|
||||
_ => do_log(log, "[resolve] bundled JRE/jar not found, falling through"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||
let p = resource_dir.join(name);
|
||||
do_log(log, &format!("[resolve] sidecar: {:?} exists={}", p, p.exists()));
|
||||
if p.exists() {
|
||||
return Ok(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(resource_dir.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||
let jar = std::fs::read_dir(&resource_dir)
|
||||
.ok()
|
||||
.and_then(|mut rd| {
|
||||
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
});
|
||||
|
||||
if let Some(jar_path) = jar {
|
||||
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar_path.to_string_lossy().into_owned()],
|
||||
working_dir: Some(resource_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||
let contents_dir = resource_dir
|
||||
.parent()
|
||||
.unwrap_or(&resource_dir)
|
||||
.to_path_buf();
|
||||
|
||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
||||
|
||||
const NATIVE_NAMES: &[&str] = &[
|
||||
"suwayomi-server-aarch64-apple-darwin",
|
||||
"suwayomi-server-x86_64-apple-darwin",
|
||||
"suwayomi-server",
|
||||
"suwayomi-launcher",
|
||||
"suwayomi-launcher.sh",
|
||||
"tachidesk-server",
|
||||
];
|
||||
|
||||
let mut found_binary: Option<ServerInvocation> = None;
|
||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
||||
|
||||
'outer: for depth in 0u8..=8 {
|
||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||
.min_depth(depth as usize)
|
||||
.max_depth(depth as usize)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_dir())
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
for dir in &entries {
|
||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
||||
|
||||
for name in NATIVE_NAMES {
|
||||
let p = dir.join(name);
|
||||
if p.exists() {
|
||||
do_log(log, &format!("[resolve] found native binary: {:?}", p));
|
||||
found_binary = Some(ServerInvocation {
|
||||
bin: p.to_string_lossy().into_owned(),
|
||||
args: vec![],
|
||||
working_dir: Some(dir.clone()),
|
||||
});
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if found_java.is_none() {
|
||||
let java_exe = dir.join("bin").join("java");
|
||||
if java_exe.exists() {
|
||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
||||
let mut search = dir.as_path();
|
||||
'jar: for _ in 0..5 {
|
||||
if let Ok(rd) = std::fs::read_dir(search) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(log, &format!("[resolve] found jar: {:?}", jar));
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bin_sibling = search.join("bin");
|
||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().ends_with(".jar") {
|
||||
let jar = entry.path();
|
||||
do_log(log, &format!("[resolve] found jar in bin/: {:?}", jar));
|
||||
found_java = Some((java_exe.clone(), jar));
|
||||
break 'jar;
|
||||
}
|
||||
}
|
||||
}
|
||||
match search.parent() {
|
||||
Some(p) => search = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(inv) = found_binary {
|
||||
return Ok(inv);
|
||||
}
|
||||
|
||||
if let Some((java, jar)) = found_java {
|
||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
||||
return Ok(ServerInvocation {
|
||||
bin: java.to_string_lossy().into_owned(),
|
||||
args: vec!["-jar".to_string(), jar.to_string_lossy().into_owned()],
|
||||
working_dir,
|
||||
});
|
||||
}
|
||||
|
||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
||||
}
|
||||
|
||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||
#[cfg(target_os = "windows")]
|
||||
let found = std::process::Command::new("where").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let found = std::process::Command::new("which").arg(name).output().map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if found {
|
||||
return Ok(ServerInvocation { bin: name.to_string(), args: vec![], working_dir: None });
|
||||
}
|
||||
}
|
||||
|
||||
Err(SpawnError::NotConfigured(
|
||||
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
||||
{
|
||||
let state = app.state::<ServerState>();
|
||||
if state.0.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let log_path = data_dir.join("moku-spawn.log");
|
||||
let _ = std::fs::create_dir_all(&data_dir);
|
||||
let mut log = std::fs::OpenOptions::new().create(true).append(true).open(&log_path).ok();
|
||||
|
||||
do_log(&mut log, &format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir));
|
||||
|
||||
seed_server_conf(&data_dir);
|
||||
|
||||
let mut invocation = resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||
do_log(&mut log, &format!("[spawn_server] resolve failed: {:?}", e));
|
||||
e
|
||||
})?;
|
||||
|
||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||
let rootdir_flag = format!(
|
||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||
data_dir.to_string_lossy()
|
||||
);
|
||||
invocation.args.insert(0, rootdir_flag);
|
||||
}
|
||||
|
||||
let working_dir = invocation.working_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
do_log(&mut log, &format!("[spawn_server] bin={:?} args={:?} cwd={:?}", invocation.bin, invocation.args, working_dir));
|
||||
|
||||
let cmd = app.shell()
|
||||
.command(&invocation.bin)
|
||||
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
|
||||
.args(&invocation.args)
|
||||
.current_dir(&working_dir);
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok((_rx, child)) => {
|
||||
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
do_log(&mut log, &format!("[spawn_server] spawn failed: {}", e));
|
||||
Err(SpawnError::SpawnFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
|
||||
kill_tachidesk(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
|
||||
use tauri_plugin_http::reqwest;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GhRelease {
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(releases.into_iter().map(|r| ReleaseInfo {
|
||||
tag_name: r.tag_name.clone(),
|
||||
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
|
||||
body: r.body.unwrap_or_default(),
|
||||
published_at: r.published_at.unwrap_or_default(),
|
||||
html_url: r.html_url,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(unused_variables)]
|
||||
async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use tauri_plugin_http::reqwest;
|
||||
use std::io::Write;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Moku")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let url = format!("https://api.github.com/repos/Youwes09/Moku/releases/tags/{}", tag);
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API returned {} for tag {}", resp.status(), tag));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Asset { name: String, browser_download_url: String, size: u64 }
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Release { assets: Vec<Asset> }
|
||||
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let release: Release = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
let asset = release.assets
|
||||
.into_iter()
|
||||
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
||||
|
||||
let total = if asset.size > 0 { Some(asset.size) } else { None };
|
||||
let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let tmp_path = std::env::temp_dir().join(&asset.name);
|
||||
let mut file = std::fs::File::create(&tmp_path).map_err(|e| e.to_string())?;
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
downloaded += chunk.len() as u64;
|
||||
let _ = app.emit("update-progress", UpdateProgress { downloaded, total });
|
||||
}
|
||||
drop(file);
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
std::process::Command::new(&tmp_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("update-launching", ());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_app(app: tauri::AppHandle) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let p = strip_unc(std::path::PathBuf::from(path.trim()));
|
||||
std::process::Command::new("explorer")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let p = std::path::Path::new(path.trim());
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(p)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_title("Choose Downloads Folder")
|
||||
.blocking_pick_folder()
|
||||
.map(|p| p.to_string())
|
||||
}
|
||||
|
||||
fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
app.path().app_data_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let filename = format!("moku-backup-{}.json", now);
|
||||
|
||||
let path = app.dialog()
|
||||
.file()
|
||||
.set_title("Save Moku app data backup")
|
||||
.set_file_name(&filename)
|
||||
.blocking_save_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
let dest = PathBuf::from(path.to_string());
|
||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(dest.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let path = app.dialog()
|
||||
.file()
|
||||
.set_title("Open Moku app data backup")
|
||||
.blocking_pick_file()
|
||||
.ok_or("Cancelled")?;
|
||||
|
||||
let src = PathBuf::from(path.to_string());
|
||||
let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
||||
let backup_dir = moku_backup_dir(&app);
|
||||
std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now));
|
||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&backup_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-"))
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for old in entries.iter().take(entries.len().saturating_sub(5)) {
|
||||
let _ = std::fs::remove_file(old.path());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||
moku_backup_dir(&app).to_string_lossy().into_owned()
|
||||
}
|
||||
pub struct ServerState(pub Mutex<Option<CommandChild>>);
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
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())
|
||||
@@ -813,28 +19,35 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_storage_info,
|
||||
get_default_downloads_path,
|
||||
check_path_exists,
|
||||
create_directory,
|
||||
migrate_downloads,
|
||||
spawn_server,
|
||||
kill_server,
|
||||
get_platform_ui_scale,
|
||||
list_releases,
|
||||
download_and_install_update,
|
||||
restart_app,
|
||||
open_path,
|
||||
pick_downloads_folder,
|
||||
export_app_data,
|
||||
import_app_data,
|
||||
auto_backup_app_data,
|
||||
get_auto_backup_dir,
|
||||
commands::storage::get_storage_info,
|
||||
commands::storage::get_default_downloads_path,
|
||||
commands::storage::check_path_exists,
|
||||
commands::storage::create_directory,
|
||||
commands::storage::migrate_downloads,
|
||||
commands::server::spawn_server,
|
||||
commands::server::kill_server,
|
||||
commands::system::get_platform_ui_scale,
|
||||
commands::system::restart_app,
|
||||
commands::system::exit_app,
|
||||
commands::system::clear_moku_cache,
|
||||
commands::system::clear_suwayomi_cache,
|
||||
commands::system::reset_suwayomi_data,
|
||||
commands::system::open_path,
|
||||
commands::system::pick_downloads_folder,
|
||||
commands::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(()))
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
kill_tachidesk(window.app_handle());
|
||||
server::kill_tachidesk(window.app_handle());
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -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",
|
||||
"productName": "Moku",
|
||||
"version": "0.9.0",
|
||||
"identifier": "io.github.Youwes09.Moku.app",
|
||||
"version": "0.9.3",
|
||||
"identifier": "io.github.MokuProject.Moku",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
@@ -27,9 +27,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -49,10 +47,6 @@
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||
"endpoints": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["appimage", "deb"],
|
||||
"externalBin": [
|
||||
"binaries/suwayomi-launcher-linux"
|
||||
],
|
||||
"resources": {
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar": "Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/bin/catch_abort.so": "catch_abort.so",
|
||||
"binaries/suwayomi-bundle/jre": "jre"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"binaries/suwayomi-bundle/bin/Suwayomi-Server.jar",
|
||||
"binaries/suwayomi-bundle/jre/**/*"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM2NEQzNDdFRjlDNUVEN0MKUldSODdjWDVmalJOTml1b0xzMDU3ZE1sNWJLZUhqUDN5cmJUdkdpeFlEVGNoQVN3UjhCc3AxV3QK",
|
||||
"endpoints": [
|
||||
"https://github.com/Youwes09/Moku/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { defaultWindowIcon } from "@tauri-apps/api/app";
|
||||
import { TrayIcon } from "@tauri-apps/api/tray";
|
||||
import { Menu } from "@tauri-apps/api/menu";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, setActiveDownloads } from "@store/state.svelte";
|
||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.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";
|
||||
@@ -31,6 +34,9 @@
|
||||
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;
|
||||
@@ -41,6 +47,30 @@
|
||||
themeEditorEditId = null;
|
||||
}
|
||||
|
||||
async function doQuit() {
|
||||
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
|
||||
await win.destroy();
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -93,6 +123,39 @@
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: [
|
||||
{
|
||||
id: "show",
|
||||
text: "Show Moku",
|
||||
action: async () => {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quit",
|
||||
text: "Quit",
|
||||
action: doQuit,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await TrayIcon.new({
|
||||
icon: await defaultWindowIcon(),
|
||||
menu,
|
||||
menuOnLeftClick: false,
|
||||
tooltip: "Moku",
|
||||
action: async (e) => {
|
||||
if (e.type === "Click") {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||
@@ -100,6 +163,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
@@ -116,8 +180,8 @@
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
unlistenClose();
|
||||
destroyRpc();
|
||||
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||
delete (window as any).__mokuShowSplash;
|
||||
};
|
||||
});
|
||||
@@ -127,7 +191,7 @@
|
||||
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||
|
||||
{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode}
|
||||
{:else if !appReady && !boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={boot.serverProbeOk}
|
||||
failed={boot.failed} notConfigured={boot.notConfigured}
|
||||
showCards={store.settings.splashCards ?? true}
|
||||
@@ -135,7 +199,7 @@
|
||||
onRetry={retryBoot}
|
||||
onBypass={() => bypassBoot(() => { appReady = true; })} />
|
||||
|
||||
{:else if boot.unsupportedMode || boot.loginRequired}
|
||||
{:else if boot.loginRequired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { appReady = true; }} />
|
||||
|
||||
@@ -145,6 +209,11 @@
|
||||
onDismiss={() => { idle = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if boot.sessionExpired}
|
||||
<SplashScreen mode="loading" ringFull={true} showCards={store.settings.splashCards ?? true} />
|
||||
<AuthGate onReady={() => { boot.sessionExpired = false; }} />
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
<div class="content">
|
||||
@@ -159,7 +228,160 @@
|
||||
</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,9 +1,24 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { fetchAuthenticated } from "../core/auth";
|
||||
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";
|
||||
|
||||
function getServerUrl(): string {
|
||||
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;
|
||||
}
|
||||
@@ -14,6 +29,14 @@ export function plainThumbUrl(path: string): string {
|
||||
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> {
|
||||
@@ -43,12 +66,13 @@ async function fetchWithRetry(
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
try {
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
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);
|
||||
}
|
||||
@@ -56,11 +80,31 @@ async function fetchWithRetry(
|
||||
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 }) },
|
||||
@@ -70,6 +114,20 @@ export async function gql<T>(
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
return json.data;
|
||||
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();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export const FETCH_CHAPTERS = `
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,19 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -17,6 +17,14 @@ export const UPDATE_EXTENSION = `
|
||||
}
|
||||
`;
|
||||
|
||||
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 }) {
|
||||
@@ -25,6 +33,124 @@ export const INSTALL_EXTERNAL_EXTENSION = `
|
||||
}
|
||||
`;
|
||||
|
||||
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 } }) {
|
||||
|
||||
@@ -33,6 +33,14 @@ export const UPDATE_MANGA_CATEGORIES = `
|
||||
}
|
||||
`;
|
||||
|
||||
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 }) {
|
||||
@@ -49,6 +57,14 @@ export const UPDATE_CATEGORY = `
|
||||
}
|
||||
`;
|
||||
|
||||
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 }) {
|
||||
@@ -65,6 +81,16 @@ export const UPDATE_CATEGORY_ORDER = `
|
||||
}
|
||||
`;
|
||||
|
||||
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: {}) {
|
||||
@@ -75,6 +101,26 @@ export const UPDATE_LIBRARY = `
|
||||
}
|
||||
`;
|
||||
|
||||
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 }
|
||||
@@ -89,3 +135,19 @@ export const RESTORE_BACKUP = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,449 +2,129 @@
|
||||
|
||||
## Manga (`mutations/manga.ts`)
|
||||
|
||||
### `FETCH_MANGA`
|
||||
Fetches and refreshes manga metadata from its source.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGA`
|
||||
Updates a single manga's library membership.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGAS`
|
||||
Bulk-updates library membership for multiple manga.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Manga IDs |
|
||||
| `inLibrary` | `Boolean` | Add/remove from library |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_MANGA_CATEGORIES`
|
||||
Adds or removes a manga from categories.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| `addTo` | `[Int!]!` | Category IDs to add to |
|
||||
| `removeFrom` | `[Int!]!` | Category IDs to remove from |
|
||||
|
||||
---
|
||||
|
||||
### `CREATE_CATEGORY`
|
||||
Creates a new manga category.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `name` | `String!` | Category name |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CATEGORY`
|
||||
Updates a category's name.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
| `name` | `String` | New name |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE_CATEGORY`
|
||||
Deletes a category by ID.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CATEGORY_ORDER`
|
||||
Moves a category to a new position.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Category ID |
|
||||
| `position` | `Int!` | New position index |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_LIBRARY`
|
||||
Triggers a library-wide metadata refresh and returns job status.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `CREATE_BACKUP`
|
||||
Creates a backup and returns its download URL.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `RESTORE_BACKUP`
|
||||
Restores a backup from an uploaded file and returns restore job status.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `backup` | `Upload!` | Backup file |
|
||||
| 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`)
|
||||
|
||||
### `FETCH_CHAPTERS`
|
||||
Fetches/refreshes the chapter list for a manga from its source.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_CHAPTER_PAGES`
|
||||
Fetches the page URLs for a specific chapter.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `MARK_CHAPTER_READ`
|
||||
Marks a single chapter as read or unread.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Chapter ID |
|
||||
| `isRead` | `Boolean!` | Read state |
|
||||
|
||||
---
|
||||
|
||||
### `MARK_CHAPTERS_READ`
|
||||
Bulk-marks multiple chapters as read or unread.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
| `isRead` | `Boolean!` | Read state |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_CHAPTERS_PROGRESS`
|
||||
Bulk-updates read state, bookmark state, and last page read for multiple chapters.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
| `isRead` | `Boolean` | Read state |
|
||||
| `isBookmarked` | `Boolean` | Bookmark state |
|
||||
| `lastPageRead` | `Int` | Last page index read |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE_DOWNLOADED_CHAPTERS`
|
||||
Deletes downloaded chapter files for the given chapter IDs.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `ids` | `[Int!]!` | Chapter IDs |
|
||||
| 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`)
|
||||
|
||||
### `ENQUEUE_DOWNLOAD`
|
||||
Adds a single chapter to the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `ENQUEUE_CHAPTERS_DOWNLOAD`
|
||||
Adds multiple chapters to the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterIds` | `[Int!]!` | Chapter IDs |
|
||||
|
||||
---
|
||||
|
||||
### `DEQUEUE_DOWNLOAD`
|
||||
Removes a chapter from the download queue.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `chapterId` | `Int!` | Chapter ID |
|
||||
|
||||
---
|
||||
|
||||
### `START_DOWNLOADER`
|
||||
Starts the downloader and returns the current queue state.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `STOP_DOWNLOADER`
|
||||
Stops the downloader and returns the current queue state.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `CLEAR_DOWNLOADER`
|
||||
Clears all items from the download queue.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_SOURCE_MANGA`
|
||||
Fetches manga from a source (browse/search), with pagination and optional filters.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `source` | `LongString!` | Source ID |
|
||||
| `type` | `FetchSourceMangaType!` | Browse type (e.g. popular, latest, search) |
|
||||
| `page` | `Int!` | Page number |
|
||||
| `query` | `String` | Search query |
|
||||
| `filters` | `[FilterChangeInput!]` | Source-specific filters |
|
||||
|
||||
---
|
||||
|
||||
### `SET_DOWNLOADS_PATH`
|
||||
Sets the downloads directory path in settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `path` | `String!` | Filesystem path |
|
||||
|
||||
---
|
||||
|
||||
### `SET_LOCAL_SOURCE_PATH`
|
||||
Sets the local source directory path in settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `path` | `String!` | Filesystem path |
|
||||
| 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`)
|
||||
|
||||
### `FETCH_EXTENSIONS`
|
||||
Fetches the latest extension list from configured repos.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_EXTENSION`
|
||||
Installs, uninstalls, or updates an extension.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `String!` | Extension package name |
|
||||
| `install` | `Boolean` | Install the extension |
|
||||
| `uninstall` | `Boolean` | Uninstall the extension |
|
||||
| `update` | `Boolean` | Update the extension |
|
||||
|
||||
---
|
||||
|
||||
### `INSTALL_EXTERNAL_EXTENSION`
|
||||
Installs an extension from an external APK URL.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `url` | `String!` | APK download URL |
|
||||
|
||||
---
|
||||
|
||||
### `SET_EXTENSION_REPOS`
|
||||
Sets the list of extension repository URLs.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `repos` | `[String!]!` | Repository URLs |
|
||||
|
||||
---
|
||||
|
||||
### `SET_SERVER_AUTH`
|
||||
Configures server authentication mode and credentials.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `authMode` | `AuthMode!` | Auth mode |
|
||||
| `authUsername` | `String!` | Username |
|
||||
| `authPassword` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `SET_SOCKS_PROXY`
|
||||
Configures SOCKS proxy settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `socksProxyEnabled` | `Boolean!` | Enable/disable proxy |
|
||||
| `socksProxyHost` | `String!` | Proxy host |
|
||||
| `socksProxyPort` | `String!` | Proxy port |
|
||||
| `socksProxyVersion` | `Int!` | SOCKS version (4 or 5) |
|
||||
| `socksProxyUsername` | `String!` | Proxy username |
|
||||
| `socksProxyPassword` | `String!` | Proxy password |
|
||||
|
||||
---
|
||||
|
||||
### `SET_FLARESOLVERR`
|
||||
Configures FlareSolverr integration settings.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `flareSolverrEnabled` | `Boolean!` | Enable/disable FlareSolverr |
|
||||
| `flareSolverrUrl` | `String!` | FlareSolverr URL |
|
||||
| `flareSolverrTimeout` | `Int!` | Request timeout (ms) |
|
||||
| `flareSolverrSessionName` | `String!` | Session name |
|
||||
| `flareSolverrSessionTtl` | `Int!` | Session TTL (seconds) |
|
||||
| `flareSolverrAsResponseFallback` | `Boolean!` | Use as fallback only |
|
||||
| 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`)
|
||||
|
||||
### `BIND_TRACK`
|
||||
Binds a manga to a remote tracker entry.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `remoteId` | `LongString!` | Remote entry ID on the tracker |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### `UPDATE_TRACK`
|
||||
Updates tracking progress, status, score, and dates for a track record.
|
||||
## New in Preview
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
| `status` | `Int` | Reading status |
|
||||
| `lastChapterRead` | `Float` | Last chapter read |
|
||||
| `scoreString` | `String` | Score in tracker's format |
|
||||
| `startDate` | `LongString` | Start date |
|
||||
| `finishDate` | `LongString` | Finish date |
|
||||
| `private` | `Boolean` | Mark as private |
|
||||
Mutations now available and not yet wired to any feature in Moku:
|
||||
|
||||
---
|
||||
|
||||
### `UNBIND_TRACK`
|
||||
Unbinds a manga from a tracker record.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
|
||||
---
|
||||
|
||||
### `FETCH_TRACK`
|
||||
Refreshes a track record from the remote tracker.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `recordId` | `Int!` | Track record ID |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_TRACKER_OAUTH`
|
||||
Initiates OAuth login for a tracker using a callback URL.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `callbackUrl` | `String!` | OAuth callback URL |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_TRACKER_CREDENTIALS`
|
||||
Logs into a tracker using username and password.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `username` | `String!` | Username |
|
||||
| `password` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `LOGOUT_TRACKER`
|
||||
Logs out of a tracker.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
|
||||
---
|
||||
|
||||
### `LOGIN_USER`
|
||||
Authenticates a user and returns access and refresh tokens.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `username` | `String!` | Username |
|
||||
| `password` | `String!` | Password |
|
||||
|
||||
---
|
||||
|
||||
### `REFRESH_TOKEN`
|
||||
Refreshes the current access token.
|
||||
|
||||
**Variables:** none
|
||||
| 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 |
|
||||
@@ -1,6 +1,6 @@
|
||||
const TRACK_RECORD_FRAGMENT = `
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
`;
|
||||
|
||||
export const BIND_TRACK = `
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,17 @@ export const FETCH_TRACK = `
|
||||
mutation FetchTrack($recordId: Int!) {
|
||||
fetchTrack(input: { recordId: $recordId }) {
|
||||
trackRecord {
|
||||
id trackerId status score displayScore lastChapterRead totalChapters startDate finishDate
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +53,7 @@ export const LOGIN_TRACKER_OAUTH = `
|
||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||
isLoggedIn
|
||||
tracker { id name isLoggedIn authUrl }
|
||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -52,7 +62,7 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
||||
isLoggedIn
|
||||
tracker { id name isLoggedIn authUrl }
|
||||
tracker { id name isLoggedIn isTokenExpired authUrl }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -60,7 +70,39 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
||||
export const LOGOUT_TRACKER = `
|
||||
mutation LogoutTracker($trackerId: Int!) {
|
||||
logoutTracker(input: { trackerId: $trackerId }) {
|
||||
tracker { id name isLoggedIn authUrl }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -68,13 +110,13 @@ export const LOGOUT_TRACKER = `
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken refreshToken
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken { accessToken }
|
||||
refreshToken(input: {}) { accessToken }
|
||||
}
|
||||
`;
|
||||
@@ -15,7 +15,7 @@ export const GET_CHAPTERS = `
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead scanlator
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const GET_DOWNLOAD_STATUS = `
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
|
||||
@@ -20,7 +20,81 @@ export const GET_EXTENSIONS = `
|
||||
export const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes { id name lang displayName iconUrl isNsfw }
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest baseUrl
|
||||
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 baseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from "./chapters";
|
||||
export * from "./downloads";
|
||||
export * from "./extensions";
|
||||
export * from "./tracking";
|
||||
export * from "./updater";
|
||||
export * from "./meta";
|
||||
@@ -2,10 +2,15 @@ export const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +28,11 @@ 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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -58,7 +67,9 @@ export const GET_DOWNLOADS_PATH = `
|
||||
export const LIBRARY_UPDATE_STATUS = `
|
||||
query LibraryUpdateStatus {
|
||||
libraryUpdateStatus {
|
||||
jobsInfo { isRunning finishedJobs totalJobs skippedMangasCount }
|
||||
jobsInfo {
|
||||
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||
}
|
||||
mangaUpdates {
|
||||
status
|
||||
manga { id title thumbnailUrl unreadCount }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,170 +2,116 @@
|
||||
|
||||
## Manga (`queries/manga.ts`)
|
||||
|
||||
### `GET_LIBRARY`
|
||||
Fetches all manga marked as in-library, including metadata, source info, chapter count, download count, and unread count.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_ALL_MANGA`
|
||||
Fetches all manga (library and non-library) with minimal fields.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_MANGA`
|
||||
Fetches a single manga by ID with full metadata and source info.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `Int!` | Manga ID |
|
||||
|
||||
---
|
||||
|
||||
### `GET_CATEGORIES`
|
||||
Fetches all categories with their order, settings, and the manga assigned to each.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_DOWNLOADED_CHAPTERS_PAGES`
|
||||
Fetches page counts for all downloaded chapters.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_DOWNLOADS_PATH`
|
||||
Fetches the configured downloads path and local source path from settings.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `LIBRARY_UPDATE_STATUS`
|
||||
Fetches the current library update job status, including progress and any manga with new chapters.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_RESTORE_STATUS`
|
||||
Fetches the status of a backup restore operation by its job ID.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | `String!` | Restore job ID |
|
||||
|
||||
---
|
||||
|
||||
### `VALIDATE_BACKUP`
|
||||
Validates a backup file and returns any missing sources or trackers.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `backup` | `Upload!` | Backup file |
|
||||
| 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`)
|
||||
|
||||
### `GET_CHAPTERS`
|
||||
Fetches all chapters for a given manga, including read/download/bookmark state and page info.
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| 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`)
|
||||
|
||||
### `GET_DOWNLOAD_STATUS`
|
||||
Fetches the current downloader state and full queue with chapter and manga info.
|
||||
|
||||
**Variables:** none
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_DOWNLOAD_STATUS` | — | Downloader state (`DownloaderState` enum) and full queue with chapter and manga info |
|
||||
|
||||
---
|
||||
|
||||
## Extensions (`queries/extensions.ts`)
|
||||
|
||||
### `GET_EXTENSIONS`
|
||||
Fetches all extensions with install status, update availability, and metadata.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SOURCES`
|
||||
Fetches all available sources with language and NSFW flags.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SETTINGS`
|
||||
Fetches extension repository settings.
|
||||
|
||||
**Variables:** none
|
||||
|
||||
---
|
||||
|
||||
### `GET_SERVER_SECURITY`
|
||||
Fetches all server security settings including auth mode, SOCKS proxy config, and FlareSolverr config.
|
||||
|
||||
**Variables:** none
|
||||
| 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`)
|
||||
|
||||
### `GET_TRACKERS`
|
||||
Fetches all trackers with login status, supported scores, statuses, and auth info.
|
||||
|
||||
**Variables:** none
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### `GET_MANGA_TRACK_RECORDS`
|
||||
Fetches all tracking records for a specific manga across all trackers.
|
||||
## Updater (`queries/updater.ts`)
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `mangaId` | `Int!` | Manga ID |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### `SEARCH_TRACKER`
|
||||
Searches a tracker for manga by query string.
|
||||
## Meta (`queries/meta.ts`)
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
| `query` | `String!` | Search query |
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_META` | `key: String!` | Single server-side key/value meta entry |
|
||||
| `GET_METAS` | — | All global meta entries as a node list |
|
||||
|
||||
---
|
||||
|
||||
### `GET_ALL_TRACKER_RECORDS`
|
||||
Fetches all trackers and their full track records, including associated manga info.
|
||||
## KoSync (`queries/kosync.ts`)
|
||||
|
||||
**Variables:** none
|
||||
| Query | Variables | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `GET_KOSYNC_STATUS` | — | KOReader sync connection status |
|
||||
|
||||
---
|
||||
|
||||
### `GET_TRACKER_RECORDS`
|
||||
Fetches track records for a specific tracker.
|
||||
## New in Preview
|
||||
|
||||
**Variables:**
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `trackerId` | `Int!` | Tracker ID |
|
||||
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 |
|
||||
@@ -2,7 +2,9 @@ export const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn authUrl supportsPrivateTracking scores
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
@@ -15,7 +17,7 @@ export const GET_MANGA_TRACK_RECORDS = `
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId remoteId title status score displayScore
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private
|
||||
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,12 +39,12 @@ export const GET_ALL_TRACKER_RECORDS = `
|
||||
query GetAllTrackerRecords {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn scores
|
||||
id name icon isLoggedIn isTokenExpired scores
|
||||
statuses { value name }
|
||||
trackRecords {
|
||||
nodes {
|
||||
id trackerId title status displayScore lastChapterRead
|
||||
totalChapters remoteUrl private
|
||||
totalChapters remoteUrl private libraryId
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 |
@@ -1,50 +1,5 @@
|
||||
import type { Manga, Source } from "@types";
|
||||
import type { Settings } from "@types";
|
||||
import { shouldHideSource } from "@core/util";
|
||||
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
|
||||
|
||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deduplicates sources by name, preferring `preferredLang` when multiple
|
||||
* sources share a name. The local source (id "0") is always excluded.
|
||||
*
|
||||
* When `applyHide` is true, sources that fail the NSFW/block check are
|
||||
* also removed — used in fan-out and cache-build paths where only
|
||||
* user-visible sources should be queried.
|
||||
*/
|
||||
export function dedupeSourcesByLang(
|
||||
sources: Source[],
|
||||
preferredLang: string,
|
||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
applyHide = false,
|
||||
): Source[] {
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
if (s.id === "0") continue;
|
||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
const existingPref = existing.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
if (newPref && !existingPref) map.set(s.name, s);
|
||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
// ── Manga predicate filters ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generic predicate pipeline — composes multiple boolean predicates into one.
|
||||
* All predicates must return true for an item to pass.
|
||||
*
|
||||
* Usage:
|
||||
* const keep = buildFilter<Manga>(
|
||||
* m => !shouldHideNsfw(m, settings),
|
||||
* m => m.inLibrary,
|
||||
* );
|
||||
* const filtered = items.filter(keep);
|
||||
*/
|
||||
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||
return (item) => predicates.every((p) => p(item));
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(msg = "Authentication required") {
|
||||
super(msg);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "moku_access_token";
|
||||
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
|
||||
|
||||
export const uiAuth = {
|
||||
getToken: () => _accessToken,
|
||||
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
||||
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
||||
};
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
clearTokens() { uiAuth.clearToken(); },
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
@@ -12,68 +32,120 @@ function getServerBase(): string {
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
skipped = false,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...(init.headers as Record<string, string> ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) { accessToken }
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
signal: timeoutSignal(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
const token: string | undefined = json?.data?.login?.accessToken;
|
||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||
uiAuth.setToken(token);
|
||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
uiAuth.clearToken();
|
||||
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
||||
Object.assign(headers, bearerHeader(_accessToken));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
return "auth_required";
|
||||
}
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
if (res.status === 401) return "auth_required";
|
||||
return "unreachable";
|
||||
} catch {
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,256 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
persistSettings,
|
||||
persistLibrary,
|
||||
persistUpdates,
|
||||
} from "@core/persistence/persist";
|
||||
|
||||
function collectAppData(): Record<string, string> {
|
||||
const data: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyAppData(data: Record<string, string>): void {
|
||||
localStorage.clear();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
|
||||
|
||||
export async function exportAppData(): Promise<void> {
|
||||
const json = JSON.stringify(collectAppData(), null, 2);
|
||||
await invoke("export_app_data", { json });
|
||||
const entries: [string, string][] = await invoke("read_store_files", {
|
||||
names: [...STORE_FILES],
|
||||
});
|
||||
|
||||
const zip = buildZip(
|
||||
entries.map(([name, content]) => ({
|
||||
name,
|
||||
bytes: new TextEncoder().encode(content),
|
||||
}))
|
||||
);
|
||||
|
||||
await invoke("export_app_data", { bytes: Array.from(zip) });
|
||||
}
|
||||
|
||||
export async function importAppData(): Promise<void> {
|
||||
const json = await invoke<string>("import_app_data");
|
||||
const data: Record<string, string> = JSON.parse(json);
|
||||
applyAppData(data);
|
||||
location.reload();
|
||||
const raw: number[] = await invoke("import_app_data");
|
||||
const files = parseZip(new Uint8Array(raw));
|
||||
|
||||
const decode = (name: string) => {
|
||||
const bytes = files.get(name);
|
||||
if (!bytes) throw new Error(`Backup is missing ${name}`);
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
};
|
||||
|
||||
const s = decode("settings.json");
|
||||
const l = decode("library.json");
|
||||
const u = decode("updates.json");
|
||||
|
||||
await Promise.all([
|
||||
persistSettings({
|
||||
settings: s.settings ?? null,
|
||||
storeVersion: s.storeVersion ?? 1,
|
||||
}),
|
||||
persistLibrary({
|
||||
history: l.history ?? [],
|
||||
bookmarks: l.bookmarks ?? [],
|
||||
markers: l.markers ?? [],
|
||||
readLog: l.readLog ?? [],
|
||||
readingStats: l.readingStats ?? null,
|
||||
dailyReadCounts: l.dailyReadCounts ?? {},
|
||||
}),
|
||||
persistUpdates({
|
||||
libraryUpdates: u.libraryUpdates ?? [],
|
||||
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
|
||||
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
|
||||
}),
|
||||
]);
|
||||
|
||||
await showExitModal();
|
||||
invoke("exit_app");
|
||||
}
|
||||
|
||||
function showExitModal(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "s-backdrop";
|
||||
backdrop.style.cssText = "z-index:99999";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.style.cssText = [
|
||||
"background:var(--bg-surface)",
|
||||
"border:1px solid var(--border-base)",
|
||||
"border-radius:var(--radius-2xl)",
|
||||
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
|
||||
"width:min(400px,calc(100vw - 40px))",
|
||||
"display:flex",
|
||||
"flex-direction:column",
|
||||
"overflow:hidden",
|
||||
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
|
||||
].join(";");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
|
||||
|
||||
const title = document.createElement("p");
|
||||
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
|
||||
title.textContent = "Import complete";
|
||||
header.appendChild(title);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
|
||||
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
|
||||
|
||||
const counter = document.createElement("p");
|
||||
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
|
||||
counter.textContent = "Closing in 3…";
|
||||
|
||||
body.append(sub, counter);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "s-btn s-btn-danger";
|
||||
btn.textContent = "Close now";
|
||||
|
||||
footer.appendChild(btn);
|
||||
modal.append(header, body, footer);
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
let secs = 3;
|
||||
const tick = setInterval(() => {
|
||||
secs--;
|
||||
counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…";
|
||||
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
|
||||
}, 1000);
|
||||
|
||||
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function autoBackupAppData(): Promise<void> {
|
||||
try {
|
||||
const json = JSON.stringify(collectAppData());
|
||||
await invoke("auto_backup_app_data", { json });
|
||||
const entries: [string, string][] = await invoke("read_store_files", {
|
||||
names: [...STORE_FILES],
|
||||
});
|
||||
const zip = buildZip(
|
||||
entries.map(([name, content]) => ({
|
||||
name,
|
||||
bytes: new TextEncoder().encode(content),
|
||||
}))
|
||||
);
|
||||
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
|
||||
} catch (e) {
|
||||
console.warn("[moku] auto-backup failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
const buf = new ArrayBuffer(30 + name.byteLength);
|
||||
const v = new DataView(buf);
|
||||
v.setUint32(0, 0x04034b50, true);
|
||||
v.setUint16(4, 20, true);
|
||||
v.setUint16(6, 0, true);
|
||||
v.setUint16(8, 0, true);
|
||||
v.setUint16(10, 0, true);
|
||||
v.setUint16(12, 0, true);
|
||||
v.setUint32(14, crc32(data), true);
|
||||
v.setUint32(18, data.byteLength, true);
|
||||
v.setUint32(22, data.byteLength, true);
|
||||
v.setUint16(26, name.byteLength, true);
|
||||
v.setUint16(28, 0, true);
|
||||
new Uint8Array(buf).set(name, 30);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
|
||||
const buf = new ArrayBuffer(46 + name.byteLength);
|
||||
const v = new DataView(buf);
|
||||
v.setUint32(0, 0x02014b50, true);
|
||||
v.setUint16(4, 20, true);
|
||||
v.setUint16(6, 20, true);
|
||||
v.setUint16(8, 0, true);
|
||||
v.setUint16(10, 0, true);
|
||||
v.setUint16(12, 0, true);
|
||||
v.setUint16(14, 0, true);
|
||||
v.setUint32(16, crc32(data), true);
|
||||
v.setUint32(20, data.byteLength, true);
|
||||
v.setUint32(24, data.byteLength, true);
|
||||
v.setUint16(28, name.byteLength, true);
|
||||
v.setUint16(30, 0, true);
|
||||
v.setUint16(32, 0, true);
|
||||
v.setUint16(34, 0, true);
|
||||
v.setUint16(36, 0, true);
|
||||
v.setUint32(38, 0, true);
|
||||
v.setUint32(42, offset, true);
|
||||
new Uint8Array(buf).set(name, 46);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
|
||||
const buf = new ArrayBuffer(22);
|
||||
const v = new DataView(buf);
|
||||
v.setUint32(0, 0x06054b50, true);
|
||||
v.setUint16(4, 0, true);
|
||||
v.setUint16(6, 0, true);
|
||||
v.setUint16(8, count, true);
|
||||
v.setUint16(10, count, true);
|
||||
v.setUint32(12, cdSize, true);
|
||||
v.setUint32(16, cdOffset, true);
|
||||
v.setUint16(20, 0, true);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const parts: Uint8Array[] = [];
|
||||
const offsets: number[] = [];
|
||||
let pos = 0;
|
||||
|
||||
for (const { name, bytes } of files) {
|
||||
const nameBytes = enc.encode(name);
|
||||
const lh = localHeader(nameBytes, bytes);
|
||||
offsets.push(pos);
|
||||
parts.push(lh, bytes);
|
||||
pos += lh.byteLength + bytes.byteLength;
|
||||
}
|
||||
|
||||
const cdParts = files.map(({ name, bytes }, i) =>
|
||||
centralHeader(enc.encode(name), bytes, offsets[i])
|
||||
);
|
||||
const cd = concat(cdParts);
|
||||
|
||||
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
|
||||
}
|
||||
|
||||
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const files = new Map<string, Uint8Array>();
|
||||
let pos = 0;
|
||||
|
||||
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
|
||||
const fnLen = view.getUint16(pos + 26, true);
|
||||
const exLen = view.getUint16(pos + 28, true);
|
||||
const cSize = view.getUint32(pos + 18, true);
|
||||
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
|
||||
const start = pos + 30 + fnLen + exLen;
|
||||
files.set(name, data.subarray(start, start + cSize));
|
||||
pos = start + cSize;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function concat(arrays: Uint8Array[]): Uint8Array {
|
||||
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let pos = 0;
|
||||
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
|
||||
return out;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { uiAuth } from "@core/auth";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
@@ -17,10 +18,18 @@ interface QueueEntry {
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||
@@ -47,7 +56,7 @@ function drain() {
|
||||
active++;
|
||||
doFetch(entry.url)
|
||||
.then(entry.resolve, entry.reject)
|
||||
.finally(() => { inflight.delete(entry.url); active--; drain(); });
|
||||
.finally(() => { active--; drain(); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +67,12 @@ function scheduleDrain() {
|
||||
}
|
||||
|
||||
function enqueue(url: string, priority: number): Promise<string> {
|
||||
const promise = new Promise<string>((resolve, reject) => { insertSorted({ url, priority, resolve, reject }); });
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
insertSorted({ url, priority, resolve, reject });
|
||||
}).catch(err => {
|
||||
inflight.delete(url);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
inflight.set(url, promise);
|
||||
scheduleDrain();
|
||||
return promise;
|
||||
@@ -98,7 +112,17 @@ export function deprioritizeQueue(): void {
|
||||
queue.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
export function cancelQueuedFetches(): void {
|
||||
const dropped = queue.splice(0);
|
||||
for (const entry of dropped) {
|
||||
inflight.delete(entry.url);
|
||||
entry.reject(new DOMException("Cancelled", "AbortError"));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
cancelQueuedFetches();
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql, plainThumbUrl } from "@api/client";
|
||||
import { gql, getServerUrl } from "@api/client";
|
||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import { dedupeRequest } from "@core/async/batchRequests";
|
||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||
@@ -6,13 +6,18 @@ import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||
const preloadedUrls = new Set<string>();
|
||||
const aspectCache = new Map<string, number>();
|
||||
|
||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||
if (!useBlob) return Promise.resolve(url);
|
||||
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
||||
return resolvedUrlCache.get(url)!;
|
||||
const cached = resolvedUrlCache.get(url);
|
||||
if (cached) return cached;
|
||||
const p = getBlobUrl(url, priority).catch(err => {
|
||||
resolvedUrlCache.delete(url);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
resolvedUrlCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function fetchPages(
|
||||
@@ -29,11 +34,8 @@ export function fetchPages(
|
||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||
.then(d => {
|
||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||||
if (useBlob) {
|
||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
||||
}
|
||||
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
|
||||
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||
pageCache.set(chapterId, urls);
|
||||
return urls;
|
||||
})
|
||||
@@ -60,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||
}
|
||||
|
||||
export function preloadImage(url: string, useBlob: boolean): void {
|
||||
if (preloadedUrls.has(url)) return;
|
||||
preloadedUrls.add(url);
|
||||
if (useBlob) {
|
||||
preloadBlobUrls([url], 0);
|
||||
return;
|
||||
}
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
export function clearResolvedUrlCache(): void {
|
||||
resolvedUrlCache.clear();
|
||||
aspectCache.clear();
|
||||
}
|
||||
|
||||
export function clearPageCache(chapterId?: number): void {
|
||||
if (chapterId !== undefined) {
|
||||
pageCache.delete(chapterId);
|
||||
@@ -73,7 +82,6 @@ export function clearPageCache(chapterId?: number): void {
|
||||
pageCache.clear();
|
||||
inflight.clear();
|
||||
resolvedUrlCache.clear();
|
||||
preloadedUrls.clear();
|
||||
aspectCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -159,3 +159,16 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||
}
|
||||
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||
}
|
||||
|
||||
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||
cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||
revokeBlobUrl(thumbnailUrl);
|
||||
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { store, linkManga } from "@store/state.svelte";
|
||||
import type { Manga } from "@types";
|
||||
|
||||
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const worker = new Worker(
|
||||
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||
const matches = e.data;
|
||||
for (const id of matches) linkManga(focal.id, id);
|
||||
worker.terminate();
|
||||
resolve(matches.length);
|
||||
};
|
||||
|
||||
worker.onerror = () => { worker.terminate(); resolve(0); };
|
||||
|
||||
worker.postMessage({
|
||||
focalTitle: focal.title,
|
||||
focalId: focal.id,
|
||||
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
||||
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface WorkerMsg {
|
||||
focalTitle: string;
|
||||
focalId: number;
|
||||
allManga: { id: number; title: string }[];
|
||||
linkedIds: number[];
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wa = new Set(norm(a));
|
||||
const wb = new Set(norm(b));
|
||||
if (!wa.size || !wb.size) return 0;
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||
return intersection / new Set([...wa, ...wb]).size;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||
const matches: number[] = [];
|
||||
|
||||
for (const m of allManga) {
|
||||
if (m.id === focalId) continue;
|
||||
if (linkedIds.includes(m.id)) continue;
|
||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||
}
|
||||
|
||||
self.postMessage(matches);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
const THUMB_SIZE = 16;
|
||||
const DUPE_THRESH = 0.12;
|
||||
|
||||
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||
|
||||
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
||||
const gray = new Uint8ClampedArray(pixels);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const o = i * 4;
|
||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
|
||||
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = THUMB_SIZE;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||
return diff / (a.length * 255);
|
||||
}
|
||||
|
||||
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
||||
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||
try {
|
||||
const thumb = await loadThumb(url);
|
||||
hashCache.set(url, thumb);
|
||||
return thumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||
return similarity(a, b) <= DUPE_THRESH;
|
||||
}
|
||||
|
||||
export function clearHashCache(): void {
|
||||
hashCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { searchWithScore } from "@core/algorithms/search";
|
||||
import { getHash, areDuplicates } from "@core/cover/coverHash";
|
||||
|
||||
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
||||
|
||||
export type CoverCandidate = {
|
||||
mangaId: number;
|
||||
url: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const FUZZY_SCORE_THRESHOLD = 0.65;
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.search = "";
|
||||
return u.href.toLowerCase();
|
||||
} catch {
|
||||
return url.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
||||
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||
}
|
||||
|
||||
function fuzzyMatchIds(
|
||||
mangaId: number,
|
||||
title: string,
|
||||
mangaById: Map<number, CoverManga & { title: string }>,
|
||||
): number[] {
|
||||
const results = searchWithScore(
|
||||
[...mangaById.values()].filter(m => m.id !== mangaId),
|
||||
title,
|
||||
m => m.title,
|
||||
);
|
||||
return results
|
||||
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
||||
.map(r => r.item.id);
|
||||
}
|
||||
|
||||
export function coverCandidatesSync(
|
||||
mangaId: number,
|
||||
title: string,
|
||||
ownUrl: string,
|
||||
mangaById: Map<number, CoverManga & { title: string }>,
|
||||
): CoverCandidate[] {
|
||||
const linkedIds = store.getLinkedMangaIds(mangaId);
|
||||
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
||||
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||
|
||||
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
||||
|
||||
const raw: { mangaId: number; url: string; label: string }[] = [
|
||||
{ mangaId, url: ownUrl, label: "This source" },
|
||||
...allIds.flatMap(id => {
|
||||
const m = mangaById.get(id);
|
||||
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
||||
}),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return raw
|
||||
.filter(c => {
|
||||
const key = normalizeUrl(c.url);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
||||
}
|
||||
|
||||
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
||||
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
||||
|
||||
const groups: number[][] = [];
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const hi = hashes[i];
|
||||
const existing = hi
|
||||
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false; })
|
||||
: undefined;
|
||||
if (existing) existing.push(i);
|
||||
else groups.push([i]);
|
||||
}
|
||||
|
||||
return groups.map(group => {
|
||||
const active = group.find(i => candidates[i].isActive) ?? group[0];
|
||||
const labels = [...new Set(group.map(i => candidates[i].label))];
|
||||
return { ...candidates[active], label: labels.join(" · ") };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { getHash, areDuplicates, clearHashCache } from "./coverHash";
|
||||
export { resolvedCover, coverCandidatesSync, dedupeByImage } from "./coverResolver";
|
||||
export type { CoverCandidate } from "./coverResolver";
|
||||
export { autoLinkLibrary } from "./autoLink";
|
||||
@@ -12,6 +12,7 @@ export interface Keybinds {
|
||||
openSettings: string;
|
||||
toggleBookmark: string;
|
||||
toggleMarker: string;
|
||||
toggleAutoScroll: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
@@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
openSettings: "o",
|
||||
toggleBookmark: "m",
|
||||
toggleMarker: "n",
|
||||
toggleAutoScroll: "s",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
@@ -44,4 +46,5 @@ export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
openSettings: "Open settings",
|
||||
toggleBookmark: "Toggle bookmark",
|
||||
toggleMarker: "Toggle marker",
|
||||
toggleAutoScroll: "Toggle auto scroll",
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
const VAULT_KEY = "moku-credential-vault";
|
||||
const SALT_ITERATIONS = 200_000;
|
||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||
|
||||
export interface VaultPayload {
|
||||
refreshToken?: string;
|
||||
basicUser?: string;
|
||||
basicPass?: string;
|
||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||
}
|
||||
|
||||
interface StoredVault {
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||
keyMat,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
KEY_USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return !!localStorage.getItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
);
|
||||
|
||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||
salt: toB64(salt),
|
||||
iv: toB64(iv),
|
||||
data: toB64(cipher),
|
||||
} satisfies StoredVault));
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const raw = localStorage.getItem(VAULT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVault(): void {
|
||||
localStorage.removeItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||
const payload = await unlockVault(oldPin);
|
||||
if (!payload) return false;
|
||||
await lockVault(newPin, payload);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
|
||||
export type { PersistedData } from "./persist";
|
||||
|
||||
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
|
||||
export type { VaultPayload } from "./credentialVault";
|
||||
@@ -0,0 +1,166 @@
|
||||
import { LazyStore } from "@tauri-apps/plugin-store";
|
||||
|
||||
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
||||
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
||||
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
||||
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
||||
|
||||
export interface PersistedData {
|
||||
settings: any;
|
||||
storeVersion: number | null;
|
||||
history: any[];
|
||||
bookmarks: any[];
|
||||
markers: any[];
|
||||
readLog: any[];
|
||||
readingStats: any | null;
|
||||
dailyReadCounts: Record<string, number>;
|
||||
libraryUpdates: any[];
|
||||
lastLibraryRefresh: number;
|
||||
acknowledgedUpdateIds: number[];
|
||||
}
|
||||
|
||||
export async function loadAllStores(): Promise<PersistedData> {
|
||||
const migrated = await migrateFromLocalStorage();
|
||||
if (migrated) return migrated;
|
||||
|
||||
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
|
||||
settingsStore.get<number>("storeVersion"),
|
||||
settingsStore.get<any>("settings"),
|
||||
libraryStore.get<any[]>("history"),
|
||||
libraryStore.get<any[]>("bookmarks"),
|
||||
libraryStore.get<any[]>("markers"),
|
||||
libraryStore.get<any[]>("readLog"),
|
||||
libraryStore.get<any>("readingStats"),
|
||||
libraryStore.get<Record<string, number>>("dailyReadCounts"),
|
||||
updatesStore.get<any[]>("libraryUpdates"),
|
||||
updatesStore.get<number>("lastLibraryRefresh"),
|
||||
updatesStore.get<number[]>("acknowledgedUpdateIds"),
|
||||
]);
|
||||
|
||||
return {
|
||||
storeVersion: sv ?? null,
|
||||
settings: s ?? null,
|
||||
history: hist ?? [],
|
||||
bookmarks: bk ?? [],
|
||||
markers: mk ?? [],
|
||||
readLog: rl ?? [],
|
||||
readingStats: rs ?? null,
|
||||
dailyReadCounts: dc ?? {},
|
||||
libraryUpdates: lu ?? [],
|
||||
lastLibraryRefresh: llr ?? 0,
|
||||
acknowledgedUpdateIds: au ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
await Promise.all([
|
||||
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
|
||||
persistLibrary({
|
||||
history: data.history ?? [],
|
||||
bookmarks: data.bookmarks ?? [],
|
||||
markers: data.markers ?? [],
|
||||
readLog: data.readLog ?? [],
|
||||
readingStats: data.readingStats ?? null,
|
||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||
}),
|
||||
persistUpdates({
|
||||
libraryUpdates: data.libraryUpdates ?? [],
|
||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||
}),
|
||||
]);
|
||||
|
||||
localStorage.removeItem("moku-store");
|
||||
|
||||
return {
|
||||
storeVersion: data.storeVersion ?? null,
|
||||
settings: data.settings ?? null,
|
||||
history: data.history ?? [],
|
||||
bookmarks: data.bookmarks ?? [],
|
||||
markers: data.markers ?? [],
|
||||
readLog: data.readLog ?? [],
|
||||
readingStats: data.readingStats ?? null,
|
||||
dailyReadCounts: data.dailyReadCounts ?? {},
|
||||
libraryUpdates: data.libraryUpdates ?? [],
|
||||
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
|
||||
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistSettings(data: { settings: any; storeVersion: number }) {
|
||||
await Promise.all([
|
||||
settingsStore.set("settings", data.settings),
|
||||
settingsStore.set("storeVersion", data.storeVersion),
|
||||
]);
|
||||
await settingsStore.save();
|
||||
}
|
||||
|
||||
export async function persistLibrary(data: {
|
||||
history: any[];
|
||||
bookmarks: any[];
|
||||
markers: any[];
|
||||
readLog: any[];
|
||||
readingStats: any;
|
||||
dailyReadCounts: Record<string, number>;
|
||||
}) {
|
||||
await Promise.all([
|
||||
libraryStore.set("history", data.history),
|
||||
libraryStore.set("bookmarks", data.bookmarks),
|
||||
libraryStore.set("markers", data.markers),
|
||||
libraryStore.set("readLog", data.readLog),
|
||||
libraryStore.set("readingStats", data.readingStats),
|
||||
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
|
||||
]);
|
||||
await libraryStore.save();
|
||||
}
|
||||
|
||||
export async function persistUpdates(data: {
|
||||
libraryUpdates: any[];
|
||||
lastLibraryRefresh: number;
|
||||
acknowledgedUpdateIds: number[];
|
||||
}) {
|
||||
await Promise.all([
|
||||
updatesStore.set("libraryUpdates", data.libraryUpdates),
|
||||
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
|
||||
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
|
||||
]);
|
||||
await updatesStore.save();
|
||||
}
|
||||
|
||||
export interface BackupEntry { url: string; name: string; }
|
||||
|
||||
export async function loadBackups(): Promise<BackupEntry[]> {
|
||||
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
||||
if (fromStore) return fromStore;
|
||||
try {
|
||||
const raw = localStorage.getItem("moku_backups");
|
||||
if (!raw) return [];
|
||||
const migrated: BackupEntry[] = JSON.parse(raw);
|
||||
await persistBackups(migrated);
|
||||
localStorage.removeItem("moku_backups");
|
||||
return migrated;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||
await backupsStore.set("backupList", list);
|
||||
await backupsStore.save();
|
||||
}
|
||||
|
||||
export async function resetAuthSettings(): Promise<void> {
|
||||
const current = await settingsStore.get<any>("settings") ?? {};
|
||||
current.serverAuthMode = "NONE";
|
||||
current.serverAuthUser = "";
|
||||
current.serverAuthPass = "";
|
||||
await settingsStore.set("settings", current);
|
||||
await settingsStore.save();
|
||||
localStorage.removeItem("moku-credential-vault");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { store } from "@store/state.svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
let themeStyleEl: HTMLStyleElement | null = null;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaHandler: (() => void) | null = null;
|
||||
|
||||
export function applyTheme() {
|
||||
const themeId = store.settings.theme ?? "dark";
|
||||
@@ -34,3 +36,32 @@ export function applyTheme() {
|
||||
themeStyleEl.textContent = css;
|
||||
document.documentElement.setAttribute("data-theme", "custom");
|
||||
}
|
||||
|
||||
function applySystemTheme(dark: boolean) {
|
||||
const themeId = dark
|
||||
? (store.settings.systemThemeDark ?? "dark")
|
||||
: (store.settings.systemThemeLight ?? "light");
|
||||
updateSettings({ theme: themeId });
|
||||
}
|
||||
|
||||
export function mountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
}
|
||||
|
||||
if (!store.settings.systemThemeSync) return;
|
||||
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
applySystemTheme(mediaQuery.matches);
|
||||
}
|
||||
|
||||
export function unmountSystemThemeSync() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaHandler);
|
||||
mediaHandler = null;
|
||||
mediaQuery = null;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './idle';
|
||||
export * from './zoom';
|
||||
export * from './touchscreen';
|
||||
@@ -0,0 +1,234 @@
|
||||
export interface LongPressOptions {
|
||||
onLongPress: (e: PointerEvent) => void;
|
||||
duration?: number;
|
||||
moveThreshold?: number;
|
||||
}
|
||||
|
||||
export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
||||
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startX = 0, startY = 0;
|
||||
let fired = false;
|
||||
|
||||
function start(e: PointerEvent) {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
startX = e.clientX; startY = e.clientY; fired = false;
|
||||
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
if (!timer) return;
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
|
||||
}
|
||||
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
||||
|
||||
node.addEventListener("pointerdown", start);
|
||||
node.addEventListener("pointermove", move);
|
||||
node.addEventListener("pointerup", cancel);
|
||||
node.addEventListener("pointerleave", cancel);
|
||||
node.addEventListener("pointercancel",cancel);
|
||||
|
||||
return {
|
||||
get fired() { return fired; },
|
||||
destroy() {
|
||||
cancel();
|
||||
node.removeEventListener("pointerdown", start);
|
||||
node.removeEventListener("pointermove", move);
|
||||
node.removeEventListener("pointerup", cancel);
|
||||
node.removeEventListener("pointerleave", cancel);
|
||||
node.removeEventListener("pointercancel",cancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TapOptions {
|
||||
onTap: (e: PointerEvent) => void;
|
||||
onDoubleTap?: (e: PointerEvent) => void;
|
||||
doubleTapGap?: number;
|
||||
}
|
||||
|
||||
export function tap(node: HTMLElement, opts: TapOptions) {
|
||||
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
|
||||
let lastTap = 0;
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
let startX = 0, startY = 0;
|
||||
const SLOP = 8;
|
||||
|
||||
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
|
||||
function up(e: PointerEvent) {
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
|
||||
const now = Date.now();
|
||||
if (onDoubleTap && now - lastTap < doubleTapGap) {
|
||||
if (pending) { clearTimeout(pending); pending = null; }
|
||||
onDoubleTap(e);
|
||||
lastTap = 0;
|
||||
} else {
|
||||
lastTap = now;
|
||||
if (onDoubleTap) {
|
||||
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
|
||||
} else {
|
||||
onTap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointerup", up);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointerup", up);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface SwipeOptions {
|
||||
onSwipeLeft?: (e: PointerEvent) => void;
|
||||
onSwipeRight?: (e: PointerEvent) => void;
|
||||
onSwipeUp?: (e: PointerEvent) => void;
|
||||
onSwipeDown?: (e: PointerEvent) => void;
|
||||
threshold?: number;
|
||||
lockAxis?: boolean;
|
||||
}
|
||||
|
||||
export function swipe(node: HTMLElement, opts: SwipeOptions) {
|
||||
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
|
||||
let startX = 0, startY = 0, active = false;
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (e.pointerType === "mouse") return;
|
||||
startX = e.clientX; startY = e.clientY; active = true;
|
||||
node.setPointerCapture(e.pointerId);
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
if (!active) return; active = false;
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
const ax = Math.abs(dx), ay = Math.abs(dy);
|
||||
if (Math.max(ax, ay) < threshold) return;
|
||||
if (lockAxis && ax > ay) {
|
||||
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
|
||||
} else if (lockAxis && ay >= ax) {
|
||||
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
|
||||
} else {
|
||||
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
|
||||
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
|
||||
}
|
||||
}
|
||||
function cancel() { active = false; }
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointerup", up);
|
||||
node.addEventListener("pointercancel", cancel);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointerup", up);
|
||||
node.removeEventListener("pointercancel", cancel);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface PinchOptions {
|
||||
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||
onPinchEnd?: (scale: number) => void;
|
||||
}
|
||||
|
||||
export interface PinchGestureOptions {
|
||||
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||
onPinchEnd?: (scale: number) => void;
|
||||
}
|
||||
|
||||
export interface PinchGesture {
|
||||
onPointerDown: (e: PointerEvent) => void;
|
||||
onPointerMove: (e: PointerEvent) => void;
|
||||
onPointerUp: (e: PointerEvent) => void;
|
||||
isPinching: () => boolean;
|
||||
}
|
||||
|
||||
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
|
||||
const { onPinch, onPinchEnd } = opts;
|
||||
const pointers = new Map<number, PointerEvent>();
|
||||
let initDist = 0;
|
||||
|
||||
function pdist(a: PointerEvent, b: PointerEvent) {
|
||||
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
function pmid(a: PointerEvent, b: PointerEvent) {
|
||||
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointers.set(e.pointerId, e);
|
||||
if (pointers.size === 2) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
initDist = pdist(a, b);
|
||||
}
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, e);
|
||||
if (pointers.size !== 2 || initDist === 0) return;
|
||||
const [a, b] = [...pointers.values()];
|
||||
onPinch(pdist(a, b) / initDist, pmid(a, b));
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (pointers.size === 2 && onPinchEnd) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
onPinchEnd(pdist(a, b) / initDist);
|
||||
}
|
||||
pointers.delete(e.pointerId);
|
||||
initDist = 0;
|
||||
}
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
|
||||
}
|
||||
|
||||
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
||||
const gesture = createPinchGesture(opts);
|
||||
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointermove", gesture.onPointerMove);
|
||||
node.addEventListener("pointerup", gesture.onPointerUp);
|
||||
node.addEventListener("pointercancel", gesture.onPointerUp);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointermove", gesture.onPointerMove);
|
||||
node.removeEventListener("pointerup", gesture.onPointerUp);
|
||||
node.removeEventListener("pointercancel", gesture.onPointerUp);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface DragScrollOptions {
|
||||
direction?: "x" | "y" | "both";
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
|
||||
const { direction = "both", onDragStart, onDragEnd } = opts;
|
||||
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (e.pointerType === "mouse") return;
|
||||
active = true;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
scrollX = node.scrollLeft; scrollY = node.scrollTop;
|
||||
node.setPointerCapture(e.pointerId);
|
||||
onDragStart?.();
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
if (!active) return;
|
||||
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
|
||||
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
|
||||
}
|
||||
function up() { if (active) { active = false; onDragEnd?.(); } }
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointermove", move);
|
||||
node.addEventListener("pointerup", up);
|
||||
node.addEventListener("pointercancel", up);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointermove", move);
|
||||
node.removeEventListener("pointerup", up);
|
||||
node.removeEventListener("pointercancel", up);
|
||||
}};
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { Manga, Source } from "@types";
|
||||
import type { Settings } from "@types";
|
||||
|
||||
// ── Class utility ─────────────────────────────────────────────────────────────
|
||||
|
||||
export { clsx as cn } from "clsx";
|
||||
|
||||
// ── Time / formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
@@ -33,85 +29,95 @@ export function formatReadTime(m: number): string {
|
||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
}
|
||||
|
||||
// ── NSFW filtering ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default genre substrings used when no user-configured list is available.
|
||||
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
|
||||
*/
|
||||
export const DEFAULT_NSFW_TAGS = [
|
||||
"adult",
|
||||
"mature",
|
||||
"hentai",
|
||||
"ecchi",
|
||||
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||
"pornograph", // catches "pornographic", "pornography"
|
||||
"18+",
|
||||
"smut",
|
||||
"lemon",
|
||||
"explicit",
|
||||
"sexual violence",
|
||||
const STRICT_TAGS: string[] = [
|
||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||
"18+", "smut", "explicit", "sexual violence",
|
||||
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if the manga's genre list contains any of the given substrings.
|
||||
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
|
||||
*/
|
||||
export function isNsfwManga(
|
||||
manga: { genre?: string[] | null },
|
||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||
): boolean {
|
||||
return (manga.genre ?? []).some(g =>
|
||||
tags.some(sub => g.toLowerCase().trim().includes(sub))
|
||||
);
|
||||
const MODERATE_TAGS: string[] = [
|
||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||
"18+", "smut", "explicit", "sexual violence",
|
||||
];
|
||||
|
||||
type ContentFilterSettings = Pick<
|
||||
Settings,
|
||||
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
|
||||
>;
|
||||
|
||||
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||
return [];
|
||||
}
|
||||
|
||||
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||
if (!blockedTags.length) return false;
|
||||
return genre.some(g => {
|
||||
const norm = g.toLowerCase().trim();
|
||||
return blockedTags.some(tag => {
|
||||
const idx = norm.indexOf(tag);
|
||||
if (idx === -1) return false;
|
||||
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||
return before && after;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single authoritative NSFW gate used by all views.
|
||||
* Returns true when the manga should be HIDDEN. Priority order:
|
||||
* 1. Source in blockedSourceIds → always hidden, even when showNsfw is on.
|
||||
* 2. showNsfw globally enabled → only blocked sources are hidden.
|
||||
* 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply.
|
||||
* 4. source.isNsfw flag → hidden.
|
||||
* 5. Genre tag match → hidden.
|
||||
*
|
||||
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||
*/
|
||||
export function shouldHideNsfw(
|
||||
manga: Pick<Manga, "genre" | "source">,
|
||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
settings: ContentFilterSettings,
|
||||
): boolean {
|
||||
if (settings.contentLevel === "unrestricted") return false;
|
||||
|
||||
const srcId = manga.source?.id;
|
||||
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||
|
||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||
if (settings.showNsfw) return false;
|
||||
if (srcId && blocked.includes(srcId)) return true;
|
||||
|
||||
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||
|
||||
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||
|
||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||
*/
|
||||
export function shouldHideSource(
|
||||
source: Pick<Source, "id" | "isNsfw">,
|
||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
settings: ContentFilterSettings,
|
||||
): boolean {
|
||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||
return !settings.showNsfw && source.isNsfw;
|
||||
if (settings.contentLevel === "unrestricted") return false;
|
||||
|
||||
if (settings.sourceOverridesEnabled) {
|
||||
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
|
||||
}
|
||||
|
||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||
return source.isNsfw;
|
||||
}
|
||||
|
||||
export function dedupeSourcesByLang(
|
||||
sources: Source[],
|
||||
preferredLang: string,
|
||||
settings: ContentFilterSettings,
|
||||
applyHide = false,
|
||||
): Source[] {
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
if (s.id === "0") continue;
|
||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
const existingPref = existing.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
if (newPref && !existingPref) map.set(s.name, s);
|
||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates sources by name. When multiple sources share a name,
|
||||
* the preferred language wins; otherwise falls back to alphabetical by lang.
|
||||
* The local source (id "0") is always excluded.
|
||||
*/
|
||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||
const byName = new Map<string, Source[]>();
|
||||
for (const src of sources) {
|
||||
@@ -127,9 +133,6 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
||||
return picked;
|
||||
}
|
||||
|
||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||
|
||||
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
|
||||
export function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@@ -140,39 +143,21 @@ export function normalizeTitle(title: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Strips all non-alphanumeric chars and collapses whitespace. */
|
||||
function norm(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* First 200 normalized chars of a description — reliable cross-source fingerprint.
|
||||
* Returns null if too short (< 60 chars) to be a trustworthy signal.
|
||||
*/
|
||||
function descFingerprint(desc: string | null | undefined): string | null {
|
||||
if (!desc) return null;
|
||||
const n = norm(desc);
|
||||
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized author + artist concatenation for tie-breaking.
|
||||
* Returns null if no author info available.
|
||||
*/
|
||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||
return parts.length ? parts.sort().join("|") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates manga across sources using title, description, and author signals,
|
||||
* plus explicit user-defined links (settings.mangaLinks).
|
||||
*
|
||||
* When two entries match, the better one is kept:
|
||||
* - Library membership wins over non-library.
|
||||
* - Otherwise higher downloadCount wins.
|
||||
* - Otherwise first occurrence wins.
|
||||
*/
|
||||
export function dedupeMangaByTitle<T extends {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -228,9 +213,6 @@ export function dedupeMangaByTitle<T extends {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lossless deduplication by ID only. Preserves first occurrence.
|
||||
*/
|
||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||
const seen = new Set<number>();
|
||||
const out: T[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[data-theme="high-contrast"] {
|
||||
[data-theme="dark"] {
|
||||
--bg-void: #000000;
|
||||
--bg-base: #080808;
|
||||
--bg-surface: #0d0d0d;
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "./high-contrast.css";
|
||||
@import "./light-contrast.css";
|
||||
@import "./original.css";
|
||||
@import "./dark.css";
|
||||
@import "./light.css";
|
||||
@import "./midnight.css";
|
||||
@import "./warm.css";
|
||||
@@ -1,29 +0,0 @@
|
||||
[data-theme="light-contrast"] {
|
||||
--bg-void: #d8d4ce;
|
||||
--bg-base: #e2deda;
|
||||
--bg-surface: #ece8e2;
|
||||
--bg-raised: #f5f2ec;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #e4e0d8;
|
||||
|
||||
--border-dim: #c4c0b8;
|
||||
--border-base: #b0aca4;
|
||||
--border-strong: #989490;
|
||||
--border-focus: #3a5a3a;
|
||||
|
||||
--text-primary: #080806;
|
||||
--text-secondary: #181612;
|
||||
--text-muted: #38342e;
|
||||
--text-faint: #706c64;
|
||||
--text-disabled: #b0aca4;
|
||||
|
||||
--accent: #2a5a2a;
|
||||
--accent-dim: #b0ccb0;
|
||||
--accent-muted: #c8dcc8;
|
||||
--accent-fg: #183818;
|
||||
--accent-bright: #1e4e1e;
|
||||
|
||||
--color-error: #8a1a1a;
|
||||
--color-error-bg: #f8e0e0;
|
||||
--color-read: #e0dcd4;
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
[data-theme="light"] {
|
||||
--bg-void: #e8e6e2;
|
||||
--bg-base: #eeece8;
|
||||
--bg-surface: #f4f2ee;
|
||||
--bg-raised: #faf8f4;
|
||||
--bg-void: #d8d4ce;
|
||||
--bg-base: #e2deda;
|
||||
--bg-surface: #ece8e2;
|
||||
--bg-raised: #f5f2ec;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #f0ede8;
|
||||
--bg-subtle: #e4e0d8;
|
||||
|
||||
--border-dim: #dedad4;
|
||||
--border-base: #d0ccc6;
|
||||
--border-strong: #bbb6ae;
|
||||
--border-focus: #5a7a5a;
|
||||
--border-dim: #c4c0b8;
|
||||
--border-base: #b0aca4;
|
||||
--border-strong: #989490;
|
||||
--border-focus: #3a5a3a;
|
||||
|
||||
--text-primary: #1a1916;
|
||||
--text-secondary: #2e2c28;
|
||||
--text-muted: #5a5750;
|
||||
--text-faint: #9a9890;
|
||||
--text-disabled: #c8c4bc;
|
||||
--text-primary: #080806;
|
||||
--text-secondary: #181612;
|
||||
--text-muted: #38342e;
|
||||
--text-faint: #706c64;
|
||||
--text-disabled: #b0aca4;
|
||||
|
||||
--accent: #4a724a;
|
||||
--accent-dim: #c8dcc8;
|
||||
--accent-muted: #deeade;
|
||||
--accent-fg: #2a5a2a;
|
||||
--accent-bright: #3a6a3a;
|
||||
--accent: #2a5a2a;
|
||||
--accent-dim: #b0ccb0;
|
||||
--accent-muted: #c8dcc8;
|
||||
--accent-fg: #183818;
|
||||
--accent-bright: #1e4e1e;
|
||||
|
||||
--color-error: #a03030;
|
||||
--color-error-bg: #fce8e8;
|
||||
--color-success: #2a6a2a;
|
||||
--color-info: #2a4a7a;
|
||||
--color-info-bg: #e8eef8;
|
||||
--color-read: #e8e4dc;
|
||||
--color-error: #8a1a1a;
|
||||
--color-error-bg: #f8e0e0;
|
||||
--color-read: #e0dcd4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[data-theme="original"] {
|
||||
--bg-void: #080808;
|
||||
--bg-base: #0c0c0c;
|
||||
--bg-surface: #101010;
|
||||
--bg-raised: #151515;
|
||||
--bg-overlay: #1a1a1a;
|
||||
--bg-subtle: #202020;
|
||||
|
||||
--border-dim: #1c1c1c;
|
||||
--border-base: #242424;
|
||||
--border-strong: #2e2e2e;
|
||||
--border-focus: #4a5c4a;
|
||||
|
||||
--text-primary: #f0efec;
|
||||
--text-secondary: #c8c6c0;
|
||||
--text-muted: #8a8880;
|
||||
--text-faint: #4e4d4a;
|
||||
--text-disabled: #2a2a28;
|
||||
|
||||
--accent: #6b8f6b;
|
||||
--accent-dim: #2a3d2a;
|
||||
--accent-muted: #1a251a;
|
||||
--accent-fg: #a8c4a8;
|
||||
--accent-bright: #8fb88f;
|
||||
|
||||
--color-error: #c47a7a;
|
||||
--color-error-bg: #1f1212;
|
||||
--color-success: #7aab7a;
|
||||
--color-info: #7a9ec4;
|
||||
--color-info-bg: #121a1f;
|
||||
}
|
||||
@@ -9,4 +9,5 @@
|
||||
--sp-10: 40px;
|
||||
|
||||
--sidebar-width: 52px;
|
||||
--titlebar-height: 36px;
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga, Source } from "@types";
|
||||
import type { CachedManga } from "@features/discover/lib/searchFilter";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
@@ -16,12 +18,14 @@
|
||||
pendingPrefill: string;
|
||||
popularResults: (Manga & { _priority: number })[];
|
||||
popularLoading: boolean;
|
||||
sourceCache: Map<number, CachedManga>;
|
||||
onPrefillConsumed: () => void;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let {
|
||||
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||
pendingPrefill, popularResults, popularLoading,
|
||||
sourceCache,
|
||||
onPrefillConsumed, onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -72,7 +76,7 @@
|
||||
let filtered = allSources;
|
||||
if (kw_selectedLangs.size > 0)
|
||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||
if (!store.settings.showNsfw)
|
||||
if (store.settings.contentLevel !== "unrestricted")
|
||||
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||
return filtered;
|
||||
}
|
||||
@@ -99,6 +103,10 @@
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||
preloadBlobUrls(
|
||||
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
|
||||
12,
|
||||
);
|
||||
const next = [...kw_results];
|
||||
next[idx] = { ...next[idx], mangas, loading: false };
|
||||
kw_results = next;
|
||||
@@ -276,7 +284,6 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
@@ -308,12 +315,12 @@
|
||||
|
||||
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); contain: layout style; }
|
||||
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
|
||||
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||
.srchSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||
import { shouldHideNsfw } from "@core/util";
|
||||
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
|
||||
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||
import {
|
||||
toCachedManga,
|
||||
type CachedManga,
|
||||
@@ -287,6 +287,9 @@
|
||||
{pendingPrefill}
|
||||
popularResults={popular_results}
|
||||
popularLoading={popular_loading}
|
||||
{sourceCache}
|
||||
query={store.searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface CachedManga {
|
||||
genreEnriched: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const COMMON_GENRES = [
|
||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
||||
@@ -66,7 +65,6 @@ export const MANGA_STATUSES: { value: string; label: string }[] = [
|
||||
{ value: "UNKNOWN", label: "Unknown" },
|
||||
];
|
||||
|
||||
|
||||
export function buildTagFilter(
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
@@ -90,13 +88,12 @@ export function buildTagFilter(
|
||||
return { and: [genrePart, statusPart] };
|
||||
}
|
||||
|
||||
|
||||
export function filterSourceCache(
|
||||
sourceCache: Map<number, CachedManga>,
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
statuses: string[],
|
||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
): CachedManga[] {
|
||||
return [...sourceCache.values()].filter((m) => {
|
||||
if (shouldHideNsfw(m as any, settings)) return false;
|
||||
@@ -118,7 +115,6 @@ export function filterSourceCache(
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function toCachedManga(
|
||||
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||
srcId: string,
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowUp, ArrowDown, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
import { longPress } from "@core/ui/touchscreen";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
import { pageProgress } from "../lib/downloadQueue";
|
||||
|
||||
interface Props {
|
||||
item: DownloadQueueItem;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isRemoving: boolean;
|
||||
isSelected: boolean;
|
||||
selectedCount: number;
|
||||
selectedErrorCount: number;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
onClearSelect: () => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
item, index, isActive, isFirst, isLast, isRemoving,
|
||||
isSelected, selectedCount, selectedErrorCount, batchWorking,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge, onClearSelect,
|
||||
item, isActive, isRemoving, isSelected,
|
||||
onRemove, onRetry, onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const manga = $derived(item.chapter.manga);
|
||||
@@ -42,143 +26,11 @@
|
||||
const isError = $derived(item.state === "ERROR");
|
||||
const pct = $derived(Math.round(item.progress * 100));
|
||||
|
||||
let menuX = $state(0);
|
||||
let menuY = $state(0);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
function openMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
menuX = e.clientX;
|
||||
menuY = e.clientY;
|
||||
menuOpen = true;
|
||||
}
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let touchMoved = false;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchMoved = false;
|
||||
const touch = e.touches[0];
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
if (touchMoved) return;
|
||||
if (selectedCount === 0) {
|
||||
onSelect(item.chapter.id, { shiftKey: false, ctrlKey: false, metaKey: false });
|
||||
} else {
|
||||
menuX = touch.clientX;
|
||||
menuY = touch.clientY;
|
||||
menuOpen = true;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onTouchMove() {
|
||||
touchMoved = true;
|
||||
cancelLongPress();
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (longPressTimer !== null) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = $derived.by<MenuEntry[]>(() => {
|
||||
const inBatch = isSelected && selectedCount > 1;
|
||||
const entries: MenuEntry[] = [];
|
||||
|
||||
if (inBatch) {
|
||||
entries.push({
|
||||
label: `Move to top (${selectedCount})`,
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onBatchReorderEdge("top"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({
|
||||
label: `Move to bottom (${selectedCount})`,
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onBatchReorderEdge("bottom"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({
|
||||
label: `Move up (${selectedCount})`,
|
||||
icon: ArrowUp,
|
||||
onClick: () => onBatchReorder("up"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({
|
||||
label: `Move down (${selectedCount})`,
|
||||
icon: ArrowDown,
|
||||
onClick: () => onBatchReorder("down"),
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
if (selectedErrorCount > 0) {
|
||||
entries.push({
|
||||
label: `Retry errors (${selectedErrorCount})`,
|
||||
icon: ArrowClockwise,
|
||||
onClick: onBatchRetry,
|
||||
disabled: batchWorking,
|
||||
function rowLongPress(node: HTMLElement) {
|
||||
return longPress(node, {
|
||||
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
label: `Remove selected (${selectedCount})`,
|
||||
icon: X,
|
||||
onClick: onBatchRemove,
|
||||
danger: true,
|
||||
disabled: batchWorking,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({ label: "Deselect all", onClick: onClearSelect });
|
||||
} else {
|
||||
if (isError) {
|
||||
entries.push({
|
||||
label: "Retry",
|
||||
icon: ArrowClockwise,
|
||||
onClick: () => onRetry(item.chapter.id),
|
||||
disabled: isRemoving,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
}
|
||||
entries.push({
|
||||
label: "Move to top",
|
||||
icon: ArrowLineUp,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "top"),
|
||||
disabled: isFirst || isActive,
|
||||
});
|
||||
entries.push({
|
||||
label: "Move to bottom",
|
||||
icon: ArrowLineDown,
|
||||
onClick: () => onReorderEdge(item.chapter.id, "bottom"),
|
||||
disabled: isLast || isActive,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({
|
||||
label: "Move up",
|
||||
icon: ArrowUp,
|
||||
onClick: () => onReorder(item.chapter.id, "up"),
|
||||
disabled: isFirst || isActive,
|
||||
});
|
||||
entries.push({
|
||||
label: "Move down",
|
||||
icon: ArrowDown,
|
||||
onClick: () => onReorder(item.chapter.id, "down"),
|
||||
disabled: isLast || isActive,
|
||||
});
|
||||
entries.push({ separator: true });
|
||||
entries.push({
|
||||
label: "Remove",
|
||||
icon: X,
|
||||
onClick: () => onRemove(item.chapter.id),
|
||||
danger: true,
|
||||
disabled: isRemoving || isActive,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -187,11 +39,12 @@
|
||||
class:row-error={isError}
|
||||
class:row-selected={isSelected}
|
||||
class:row-removing={isRemoving}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabindex="0"
|
||||
use:rowLongPress
|
||||
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
|
||||
oncontextmenu={openMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={cancelLongPress}
|
||||
ontouchmove={onTouchMove}
|
||||
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
|
||||
>
|
||||
{#if manga?.thumbnailUrl}
|
||||
<div class="thumb">
|
||||
@@ -229,12 +82,6 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isActive}
|
||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
|
||||
<ArrowUp size={11} weight="light" />
|
||||
</button>
|
||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
|
||||
<ArrowDown size={11} weight="light" />
|
||||
</button>
|
||||
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||
</button>
|
||||
@@ -243,10 +90,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<ContextMenu x={menuX} y={menuY} items={menuItems} onClose={() => (menuOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -263,118 +106,33 @@
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||
.row.row-active { border-color: var(--accent-dim); }
|
||||
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
|
||||
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
.thumb {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-overlay);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||
|
||||
.manga-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.chapter-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--border-base);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s ease;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.5; }
|
||||
.row-active .progress-bar { opacity: 1; }
|
||||
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
|
||||
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.pages-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.actions { display: flex; align-items: center; gap: 2px; }
|
||||
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
.action-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
@@ -9,28 +9,17 @@
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
batchWorking: boolean;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onClearSelect: () => void;
|
||||
onBatchRemove: () => void;
|
||||
onBatchRetry: () => void;
|
||||
onBatchReorder: (dir: "up" | "down") => void;
|
||||
onBatchReorderEdge: (edge: "top" | "bottom") => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected, batchWorking,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect, onClearSelect,
|
||||
onBatchRemove, onBatchRetry, onBatchReorder, onBatchReorderEdge,
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -41,117 +30,23 @@
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
<div class="list-header">
|
||||
<div class="info-wrap">
|
||||
<button class="info-btn" tabindex="-1" aria-label="Selection help">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<circle cx="6" cy="6" r="5.25" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="5.25" y="5" width="1.5" height="3.5" rx="0.75" fill="currentColor"/>
|
||||
<circle cx="6" cy="3.25" r="0.85" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="info-popover" role="tooltip">
|
||||
<span>Click to select</span>
|
||||
<span>Shift+click to range select</span>
|
||||
<span>Ctrl+click to toggle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
index={i}
|
||||
isActive={i === 0 && isRunning}
|
||||
isFirst={i === 0}
|
||||
isLast={i === queue.length - 1}
|
||||
isRemoving={dequeueing.has(item.chapter.id)}
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
selectedCount={selected.size}
|
||||
{selectedErrorCount}
|
||||
{batchWorking}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
{onClearSelect}
|
||||
{onBatchRemove}
|
||||
{onBatchRetry}
|
||||
{onBatchReorder}
|
||||
{onBatchReorderEdge}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
color: var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.info-btn:hover { color: var(--text-muted); }
|
||||
|
||||
.info-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-popover span {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.info-wrap:hover .info-popover { display: flex; }
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash } from "phosphor-svelte";
|
||||
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
|
||||
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
|
||||
import DownloadQueue from "./DownloadQueue.svelte";
|
||||
import { downloadStore } from "../store/downloadState.svelte";
|
||||
import { formatEta } from "../lib/downloadQueue";
|
||||
@@ -10,6 +11,11 @@
|
||||
});
|
||||
|
||||
let selectAnchor = $state<number | null>(null);
|
||||
let moveBy = $state(1);
|
||||
|
||||
const selectedErrorCount = $derived(
|
||||
downloadStore.queue.filter((i) => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
|
||||
);
|
||||
|
||||
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
@@ -39,12 +45,25 @@
|
||||
selectAnchor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
downloadStore.clearSelection();
|
||||
selectAnchor = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={downloadStore.autoRetryEnabled}
|
||||
onclick={() => downloadStore.toggleAutoRetry()}
|
||||
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
|
||||
>
|
||||
<Repeat size={14} weight="regular" />
|
||||
</button>
|
||||
{#if downloadStore.hasErrored}
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -95,147 +114,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" onclick={handleClickOff}>
|
||||
<div class="status-bar">
|
||||
<div class="bar-wrap">
|
||||
<div class="status-bar" role="none">
|
||||
<div class="status-dot" class:active={downloadStore.isRunning}></div>
|
||||
<span class="status-text">
|
||||
{downloadStore.togglingPlay
|
||||
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
|
||||
: downloadStore.isRunning ? "Downloading" : "Paused"}
|
||||
</span>
|
||||
{#if downloadStore.selected.size > 0}
|
||||
<div class="sel-controls">
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
|
||||
<ArrowLineUp size={12} weight="bold" />
|
||||
</button>
|
||||
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
|
||||
<CaretUp size={12} weight="bold" />
|
||||
</button>
|
||||
<input
|
||||
class="move-input"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={moveBy}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
|
||||
<ArrowLineDown size={12} weight="bold" />
|
||||
</button>
|
||||
{#if selectedErrorCount > 0}
|
||||
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
|
||||
<ArrowClockwise size={12} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="bar-sep"></div>
|
||||
<span class="status-count">{downloadStore.selected.size} selected</span>
|
||||
{:else}
|
||||
<div class="status-right">
|
||||
{#if downloadStore.isRunning && downloadStore.eta !== null}
|
||||
<span class="status-eta">{formatEta(downloadStore.eta)} left</span>
|
||||
{/if}
|
||||
<span class="status-count">{downloadStore.queue.length} queued</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === 'Escape' && handleClickOff()}>
|
||||
<DownloadQueue
|
||||
queue={downloadStore.queue}
|
||||
loading={downloadStore.loading}
|
||||
isRunning={downloadStore.isRunning}
|
||||
dequeueing={downloadStore.dequeueing}
|
||||
selected={downloadStore.selected}
|
||||
batchWorking={downloadStore.batchWorking}
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onSelect={handleSelect}
|
||||
onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }}
|
||||
onBatchRemove={() => downloadStore.dequeueSelected()}
|
||||
onBatchRetry={() => downloadStore.retrySelected()}
|
||||
onBatchReorder={(dir) => downloadStore.reorderSelected(dir)}
|
||||
onBatchReorderEdge={(edge) => downloadStore.reorderSelectedToEdge(edge)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
|
||||
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); opacity: 0.8; }
|
||||
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.status-bar { cursor: default; }
|
||||
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||||
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
|
||||
|
||||
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
|
||||
.status-text {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.status-eta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
|
||||
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.move-input:focus { color: var(--text-primary); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
const RETRY_DELAY_MS = 20_000;
|
||||
|
||||
export interface AutoRetryHandle {
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function startAutoRetry(
|
||||
getQueue: () => DownloadQueueItem[],
|
||||
isRunning: () => boolean,
|
||||
retryErrored: () => Promise<void>,
|
||||
): AutoRetryHandle {
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function tick() {
|
||||
if (stopped) return;
|
||||
|
||||
const queue = getQueue();
|
||||
const errored = queue.filter(i => i.state === "ERROR");
|
||||
const active = queue.filter(i => i.state !== "ERROR");
|
||||
|
||||
if (errored.length > 0 && active.length === 0 && !isRunning()) {
|
||||
await retryErrored().catch(() => {});
|
||||
}
|
||||
|
||||
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, RETRY_DELAY_MS);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD,
|
||||
ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD,
|
||||
} from "@api/mutations";
|
||||
import { addToast, setActiveDownloads } from "@store/state.svelte";
|
||||
import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte";
|
||||
import { boot } from "@store/boot.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||
import {
|
||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||
isRunning, getErrored, calcSpeed, estimateEta,
|
||||
type SpeedSample,
|
||||
} from "../lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
@@ -24,17 +26,40 @@ class DownloadStore {
|
||||
pagesPerSec: number | null = $state(null);
|
||||
eta: number | null = $state(null);
|
||||
|
||||
toastsEnabled = $state(true);
|
||||
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||
|
||||
private lastSample: SpeedSample | null = null;
|
||||
private prevQueue: DownloadQueueItem[] = [];
|
||||
private autoRetryHnd: AutoRetryHandle | null = null;
|
||||
|
||||
get queue() { return this.status?.queue ?? []; }
|
||||
get isRunning() { return isRunning(this.status?.state); }
|
||||
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
|
||||
get hasErrored() { return this.erroredIds.size > 0; }
|
||||
|
||||
toggleToasts() { this.toastsEnabled = !this.toastsEnabled; }
|
||||
toggleToasts() {
|
||||
const next = !this.toastsEnabled;
|
||||
updateSettings({ downloadToastsEnabled: next });
|
||||
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
|
||||
}
|
||||
|
||||
toggleAutoRetry() {
|
||||
if (this.autoRetryEnabled) {
|
||||
this.autoRetryHnd?.stop();
|
||||
this.autoRetryHnd = null;
|
||||
updateSettings({ downloadAutoRetry: false });
|
||||
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
|
||||
} else {
|
||||
updateSettings({ downloadAutoRetry: true });
|
||||
this.autoRetryHnd = startAutoRetry(
|
||||
() => this.queue,
|
||||
() => this.isRunning,
|
||||
() => this.retryAllErrored(),
|
||||
);
|
||||
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
detectTransitions(next: DownloadQueueItem[]) {
|
||||
if (!this.toastsEnabled) return;
|
||||
@@ -81,6 +106,7 @@ class DownloadStore {
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (boot.sessionExpired) return;
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => this.applyStatus(d.downloadStatus))
|
||||
.catch(console.error)
|
||||
@@ -101,7 +127,10 @@ class DownloadStore {
|
||||
this.applyStatus(d.startDownloader.downloadStatus);
|
||||
}
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.togglingPlay = false; }
|
||||
finally {
|
||||
this.togglingPlay = false;
|
||||
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
|
||||
}
|
||||
}
|
||||
|
||||
async clear() {
|
||||
@@ -113,6 +142,7 @@ class DownloadStore {
|
||||
try {
|
||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||
this.applyStatus(d.clearDownloader.downloadStatus);
|
||||
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.clearing = false; }
|
||||
}
|
||||
@@ -137,6 +167,7 @@ class DownloadStore {
|
||||
try {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
@@ -160,6 +191,7 @@ class DownloadStore {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
@@ -173,6 +205,7 @@ class DownloadStore {
|
||||
if (ids.length > 0) {
|
||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
@@ -254,25 +287,29 @@ class DownloadStore {
|
||||
if (this.batchWorking || this.selected.size === 0) return;
|
||||
this.batchWorking = true;
|
||||
|
||||
const pinned = this.queue.filter((i) => this.selected.has(i.chapter.id));
|
||||
const rest = this.queue.filter((i) => !this.selected.has(i.chapter.id));
|
||||
const newQueue = edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const active = this.queue.slice(0, first);
|
||||
const moveable = this.queue.slice(first);
|
||||
const pinned = moveable.filter((i) => this.selected.has(i.chapter.id));
|
||||
const rest = moveable.filter((i) => !this.selected.has(i.chapter.id));
|
||||
const newQueue = edge === "top"
|
||||
? [...active, ...pinned, ...rest]
|
||||
: [...active, ...rest, ...pinned];
|
||||
if (this.status) this.status = { ...this.status, queue: newQueue };
|
||||
|
||||
const first = this.isRunning ? 1 : 0;
|
||||
const last = this.queue.length - 1;
|
||||
|
||||
try {
|
||||
if (edge === "top") {
|
||||
for (const item of [...pinned].reverse()) {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: first },
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const item of pinned) {
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>(
|
||||
REORDER_DOWNLOAD, { chapterId: item.chapter.id, to: last },
|
||||
REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte";
|
||||
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Extension } from "@types/index";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
base: string;
|
||||
primary: Extension;
|
||||
@@ -10,17 +12,27 @@
|
||||
expanded: boolean;
|
||||
working: Set<string>;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
libraryCount: number;
|
||||
onToggle: (base: string) => void;
|
||||
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
|
||||
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
|
||||
}
|
||||
|
||||
let { base, primary, variants, expanded, working, anims, onToggle, onMutate }: Props = $props();
|
||||
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
|
||||
|
||||
const clickable = $derived(primary.isInstalled);
|
||||
|
||||
const hasVariants = $derived(variants.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="group">
|
||||
<div class="row">
|
||||
<svelte:element
|
||||
this={clickable ? "button" : "div"}
|
||||
class="row"
|
||||
class:row-clickable={clickable}
|
||||
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
|
||||
>
|
||||
<Thumbnail
|
||||
src={primary.iconUrl}
|
||||
alt={primary.name}
|
||||
@@ -31,6 +43,13 @@
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta">
|
||||
<span class="lang-tag">{primary.lang.toUpperCase()}</span>
|
||||
{#if primary.isInstalled}
|
||||
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
|
||||
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
|
||||
{libraryCount > 0 ? libraryCount : 0}
|
||||
|
||||
</span>
|
||||
{/if}
|
||||
v{primary.versionName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -39,22 +58,24 @@
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if primary.hasUpdate}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "update")}>Update</button>
|
||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
||||
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else if primary.isInstalled}
|
||||
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
|
||||
<div class="row-actions">
|
||||
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
|
||||
{/if}
|
||||
|
||||
{#if hasVariants}
|
||||
<button class="expand-btn" onclick={() => onToggle(base)} title="{variants.length + 1} languages">
|
||||
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
|
||||
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
<span class="expand-count">{variants.length + 1}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
{#if expanded && hasVariants}
|
||||
<div class="variants" class:variants-anim={anims}>
|
||||
@@ -83,15 +104,18 @@
|
||||
|
||||
<style>
|
||||
.group { display: flex; flex-direction: column; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row-clickable { cursor: pointer; }
|
||||
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
.action-btn:hover { filter: brightness(1.1); }
|
||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
@@ -106,5 +130,5 @@
|
||||
.variant-row:hover { background: var(--bg-raised); }
|
||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.variant-actions { flex-shrink: 0; }
|
||||
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
|
||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +8,7 @@
|
||||
panel: Panel;
|
||||
refreshing: boolean;
|
||||
updateCount: number;
|
||||
updatingAll: boolean;
|
||||
availableLangs: string[];
|
||||
langFilter: string | null;
|
||||
anims: boolean;
|
||||
@@ -18,14 +19,15 @@
|
||||
onLang: (lang: string | null) => void;
|
||||
onPanel: (p: Panel) => void;
|
||||
onRefresh: () => void;
|
||||
onUpdateAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filter, search, panel, refreshing, updateCount,
|
||||
filter, search, panel, refreshing, updateCount, updatingAll,
|
||||
availableLangs, langFilter,
|
||||
anims, tabIndicator,
|
||||
tabsEl = $bindable(),
|
||||
onFilter, onSearch, onLang, onPanel, onRefresh,
|
||||
onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -57,6 +59,11 @@
|
||||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
{#if updateCount > 0}
|
||||
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
|
||||
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +97,8 @@
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.icon-btn.update-badge { color: var(--accent-fg); }
|
||||
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
|
||||
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
|
||||
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
|
||||
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { gql } from "@api/client";
|
||||
import { setPreviewManga } from "@store/state.svelte";
|
||||
import { GET_LIBRARY, GET_SOURCES } from "@api/queries";
|
||||
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "../lib/extensionLibrary";
|
||||
import SourceMigrateModal from "../panels/SourceMigrateModal.svelte";
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
|
||||
interface Props {
|
||||
pkgName: string;
|
||||
extensionName: string;
|
||||
iconUrl: string;
|
||||
cols: number;
|
||||
cropCovers: boolean;
|
||||
statsAlways: boolean;
|
||||
anims: boolean;
|
||||
sources: SourceEntry[];
|
||||
onBack: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||
|
||||
let groups: SourceLibrary[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
|
||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||
|
||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? allManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase()))
|
||||
: allManga
|
||||
);
|
||||
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [libData, srcData] = await Promise.all([
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES),
|
||||
]);
|
||||
sourceNodes = srcData.sources.nodes;
|
||||
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openMigrate(group: SourceLibrary) {
|
||||
const node = sourceNodes.find(s => s.id === group.sourceId);
|
||||
migrateTarget = {
|
||||
sourceId: group.sourceId,
|
||||
sourceName: group.displayName,
|
||||
iconUrl: (node as any)?.iconUrl ?? iconUrl,
|
||||
manga: group.manga,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="header-btn" onclick={onBack}>
|
||||
<ArrowLeft size={14} weight="bold" />
|
||||
</button>
|
||||
{#if iconUrl}
|
||||
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
{/if}
|
||||
<div class="title-block">
|
||||
<span class="eyebrow">In Library</span>
|
||||
<span class="title">{extensionName}</span>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<span class="count-badge">{allManga.length}</span>
|
||||
{/if}
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
</div>
|
||||
{#if sources.length > 0}
|
||||
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each Array(12) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">
|
||||
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
|
||||
</div>
|
||||
{:else}
|
||||
{#if groups.length > 1}
|
||||
<div class="source-groups">
|
||||
{#each groups as group}
|
||||
<div class="source-group-header">
|
||||
<span class="source-group-name">{group.displayName}</span>
|
||||
<span class="source-group-count">{group.manga.length}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(group)} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if groups.length === 1}
|
||||
<div class="single-source-bar">
|
||||
<span class="source-group-name">{groups[0].displayName}</span>
|
||||
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
|
||||
<Swap size={12} weight="bold" />
|
||||
Migrate source
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each filtered as m (m.id)}
|
||||
{@const isCompleted = !m.unreadCount && m.downloadCount > 0}
|
||||
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
|
||||
<div class="cover-wrap" class:completed={isCompleted}>
|
||||
<Thumbnail
|
||||
src={resolvedCover(m.id, m.thumbnailUrl)}
|
||||
alt={m.title}
|
||||
class="cover"
|
||||
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||
<div class="overlay-badges">
|
||||
{#if isCompleted}
|
||||
<span class="badge badge-done">✓ Done</span>
|
||||
{:else if m.unreadCount}
|
||||
<span class="badge badge-unread">{m.unreadCount} new</span>
|
||||
{/if}
|
||||
{#if m.downloadCount}
|
||||
<span class="badge badge-dl">↓ {m.downloadCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if migrateTarget}
|
||||
<SourceMigrateModal
|
||||
sourceId={migrateTarget.sourceId}
|
||||
sourceName={migrateTarget.sourceName}
|
||||
sourceIconUrl={migrateTarget.iconUrl}
|
||||
manga={migrateTarget.manga}
|
||||
onClose={() => migrateTarget = null}
|
||||
onDone={() => { migrateTarget = null; load(); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
|
||||
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
|
||||
.title-block { display: flex; flex-direction: column; gap: 1px; }
|
||||
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
|
||||
|
||||
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; margin-left: auto; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.source-group-header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.single-source-bar { display: flex; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-dim); }
|
||||
.source-group-name { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); font-weight: var(--weight-medium); }
|
||||
.source-group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); }
|
||||
.migrate-btn { display: flex; align-items: center; gap: 5px; margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 9px; border-radius: var(--radius-sm); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.migrate-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
|
||||
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
|
||||
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
|
||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||
|
||||
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
|
||||
.card-info-overlay.anim { transition: opacity 0.18s ease; }
|
||||
.card-info-overlay.instant { transition: none; }
|
||||
.card-info-overlay.always { opacity: 1; }
|
||||
.card:hover .card-info-overlay { opacity: 1; }
|
||||
|
||||
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
|
||||
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card.anims .card-title { transition: color var(--t-base); }
|
||||
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||
</style>
|
||||
@@ -3,14 +3,21 @@
|
||||
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries";
|
||||
import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries";
|
||||
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
|
||||
import type { Extension } from "@types/index";
|
||||
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary";
|
||||
import ExtensionFilters from "./ExtensionFilters.svelte";
|
||||
import ExtensionCard from "./ExtensionCard.svelte";
|
||||
import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte";
|
||||
import ExtensionLibrary from "./ExtensionLibrary.svelte";
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
const cols = $derived(store.settings.libraryCols ?? 5);
|
||||
const cropCovers = $derived(store.settings.cropCovers ?? true);
|
||||
const statsAlways = $derived(store.settings.statsAlways ?? false);
|
||||
|
||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
let tabIndicator = $state({ left: 0, width: 0 });
|
||||
|
||||
@@ -29,9 +36,19 @@
|
||||
let search = $state("");
|
||||
let langFilter = $state<string | null>(null);
|
||||
let working = $state(new Set<string>());
|
||||
let updatingAll = $state(false);
|
||||
let expanded = $state(new Set<string>());
|
||||
let panel = $state<Panel>(null);
|
||||
|
||||
type SourceEntry = { id: string; displayName: string };
|
||||
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
|
||||
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
|
||||
|
||||
let settingsTarget = $state<SettingsTarget | null>(null);
|
||||
let libraryTarget = $state<LibraryTarget | null>(null);
|
||||
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
|
||||
let libCountByPkg = $state<Record<string, number>>({});
|
||||
|
||||
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
let externalUrl = $state("");
|
||||
@@ -46,8 +63,25 @@
|
||||
let savingRepos = $state(false);
|
||||
|
||||
async function load() {
|
||||
const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error);
|
||||
if (d) extensions = d.extensions.nodes;
|
||||
const [extData, srcData, libData] = await Promise.all([
|
||||
gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error),
|
||||
gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error),
|
||||
gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error),
|
||||
]);
|
||||
if (extData) extensions = extData.extensions.nodes;
|
||||
if (srcData) {
|
||||
const map: Record<string, SourceEntry[]> = {};
|
||||
for (const s of srcData.sources.nodes) {
|
||||
if (!s.isConfigurable || !s.extension?.pkgName) continue;
|
||||
const pkg = s.extension.pkgName;
|
||||
if (!map[pkg]) map[pkg] = [];
|
||||
map[pkg].push({ id: s.id, displayName: s.displayName });
|
||||
}
|
||||
sourcesByPkg = map;
|
||||
}
|
||||
if (libData && srcData) {
|
||||
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalManga() {
|
||||
@@ -124,6 +158,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
const pending = extensions.filter((e) => e.hasUpdate);
|
||||
if (!pending.length || updatingAll) return;
|
||||
updatingAll = true;
|
||||
for (const ext of pending) await mutate(ext.pkgName, "update");
|
||||
updatingAll = false;
|
||||
addToast({ kind: "success", title: "All extensions updated", body: `${pending.length} extension${pending.length === 1 ? "" : "s"} updated` });
|
||||
}
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
const err = validateUrl(url, ".apk");
|
||||
@@ -203,16 +246,28 @@
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
{#if libraryTarget}
|
||||
<ExtensionLibrary
|
||||
pkgName={libraryTarget.pkgName}
|
||||
extensionName={libraryTarget.extensionName}
|
||||
iconUrl={libraryTarget.iconUrl}
|
||||
{cols} {cropCovers} {statsAlways} {anims}
|
||||
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
|
||||
onBack={() => libraryTarget = null}
|
||||
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="root anim-fade-in">
|
||||
<ExtensionFilters
|
||||
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
|
||||
{anims} {tabIndicator}
|
||||
{anims} {tabIndicator} {updatingAll}
|
||||
bind:tabsEl
|
||||
onFilter={setFilter}
|
||||
onSearch={(q) => search = q}
|
||||
onLang={(l) => langFilter = l}
|
||||
onPanel={openPanel}
|
||||
onRefresh={fetchFromRepo}
|
||||
onUpdateAll={updateAll}
|
||||
/>
|
||||
|
||||
{#if panel === "apk"}
|
||||
@@ -296,9 +351,12 @@
|
||||
{#each groups as { base, primary, variants }}
|
||||
<ExtensionCard
|
||||
{base} {primary} {variants} {working} {anims}
|
||||
sources={sourcesByPkg[primary.pkgName] ?? []}
|
||||
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
|
||||
expanded={expanded.has(base)}
|
||||
onToggle={toggleExpand}
|
||||
onMutate={mutate}
|
||||
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
|
||||
/>
|
||||
{/each}
|
||||
{#if !showLocal && groups.length === 0}
|
||||
@@ -307,13 +365,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if settingsTarget}
|
||||
<ExtensionSettingsPanel
|
||||
extensionName={settingsTarget.extensionName}
|
||||
iconUrl={settingsTarget.iconUrl}
|
||||
sources={settingsTarget.sources}
|
||||
onClose={() => settingsTarget = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
|
||||
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
|
||||
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
|
||||
|
||||