Compare commits

...

14 Commits

Author SHA1 Message Date
Youwes09 5e591411e4 Chore: Patch PKGBUILD for AUR (V1) 2026-05-17 16:50:40 -05:00
Youwes09 8aaaf2451a Fix: Added ServerBinary Off & Flatpak Patches 2026-05-17 16:27:57 -05:00
Youwes09 f161fc08a2 Chore: Post-Bump 0.9.4 2026-05-17 00:14:22 -05:00
Youwes09 239960683b Chore: Bump for 0.9.4 2026-05-17 00:12:55 -05:00
Youwes09 3b5efc85d0 Fix: ReaderOverlay Draggable 2026-05-17 00:01:55 -05:00
Youwes09 7df3846e75 Feat: Improved PageLoder & Keybinds Fix 2026-05-16 23:36:15 -05:00
Youwes09 01f123f5be Fix: GlobalUIZoom Affecting MangaDisplay (#82) 2026-05-16 23:06:10 -05:00
Youwes09 0e2371096b Feat: Basic ExtensionLibrary Filter 2026-05-16 22:50:00 -05:00
Youwes09 47ae80a7d2 Fix: Cache Adjustments (WIP) 2026-05-16 22:46:45 -05:00
Youwes09 d98547d540 Fix: Cache-Boot (KCEF Corruption) 2026-05-17 03:29:39 -05:00
Youwes09 897ecfd316 Fix: Clear Moku Cache & SelectPortal Zoom (#82) 2026-05-16 22:05:13 -05:00
Youwes09 e3abc72f1b Fix: Duplicate App Instances (#83) 2026-05-16 15:41:07 -05:00
Youwes09 6b56db7cf2 Fix: Exit Button Works 2026-05-16 15:31:13 -05:00
Youwes09 93cedca6b5 Chore: Post-Bump 0.9.3 2026-05-16 08:00:11 -05:00
32 changed files with 918 additions and 327 deletions
+13 -6
View File
@@ -1,5 +1,5 @@
pkgname=moku
pkgver=0.9.3
pkgver=0.9.4
pkgrel=1
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
arch=('x86_64')
@@ -13,18 +13,25 @@ depends=(
)
makedepends=(
'rust'
'cargo'
'nodejs'
'pnpm'
'nodejs-pnpm'
)
optdepends=(
'discord: Discord rich presence'
)
options=('!strip')
source=(
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
)
noextract=("Suwayomi-Server-v2.1.2087.jar")
sha256sums=(
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
)
b2sums=(
'SKIP'
'SKIP'
)
prepare() {
cd "Moku-$pkgver"
@@ -52,7 +59,7 @@ package() {
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.downloadAsCbz = true
+1 -1
View File
@@ -22,7 +22,7 @@
perSystem =
{ system, lib, ... }:
let
version = "0.9.3";
version = "0.9.4";
pkgs = import inputs.nixpkgs {
inherit system;
+76 -11
View File
@@ -32,6 +32,77 @@ build-options:
CARGO_HOME: /run/build/moku/cargo
modules:
- name: intltool
buildsystem: autotools
sources:
- type: archive
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
- name: libdbusmenu
buildsystem: autotools
build-options:
cflags: -Wno-error
env:
HAVE_VALGRIND_FALSE: '#'
HAVE_VALGRIND_TRUE: ''
config-opts:
- --with-gtk=3
- --disable-static
- --disable-dumper
- --disable-tests
- --disable-gtk-doc
- --disable-vala
- --disable-introspection
cleanup:
- /include
- /libexec
- /lib/pkgconfig
- /lib/*.la
- /share/doc
- /share/libdbusmenu
- /share/gtk-doc
- /share/gir-1.0
sources:
- type: archive
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
- name: libayatana-ido
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/ayatana-ido.git
tag: 0.10.3
- name: libayatana-indicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
tag: 0.9.4
- name: libayatana-appindicator
buildsystem: cmake-ninja
config-opts:
- -DENABLE_TESTS=OFF
- -DENABLE_BINDINGS_MONO=OFF
- -DENABLE_BINDINGS_VALA=OFF
- -DGSETTINGS_COMPILE=OFF
sources:
- type: git
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
tag: 0.5.93
- type: shell
commands:
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
- name: openjdk
buildsystem: simple
build-commands:
@@ -52,9 +123,6 @@ modules:
- type: inline
dest-filename: catch_abort.c
contents: |
// Linux only:
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
@@ -117,19 +185,16 @@ modules:
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR"
# Seed conf on first run
if [ ! -f "$DATA_DIR/server.conf" ]; then
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
fi
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
# Append keys if absent
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
@@ -166,10 +231,10 @@ modules:
CARGO_HOME: /run/build/moku/cargo
XDG_DATA_HOME: /run/build/moku/xdg-data
TAURI_SKIP_DEVSERVER_CHECK: 'true'
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
build-commands:
- tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
@@ -179,11 +244,11 @@ modules:
sources:
- type: git
url: https://github.com/moku-project/Moku.git
tag: v0.9.3
commit: 83711c155d3e60ab4e2411ea6e0098231d76f8b9
tag: v0.9.4
commit: 239960683b6c7f1347e1798b0e179a8a46628728
- type: file
path: packaging/frontend-dist.tar.gz
sha256: c690eb3cb24e89fec3f4e92f7a4a82d9a465b58f6680a332c1e44f1361ac96af
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
- packaging/cargo-sources.json
- type: inline
dest: src-tauri/.cargo
+1 -1
View File
@@ -10,7 +10,7 @@ stdenv.mkDerivation {
pname = "moku-frontend";
inherit version src;
fetcherVersion = 1;
hash = "sha256-eRuSSRhNmJ09mp/uhbG+NFeiOZ5dTOdJ94OwdP6IkN0=";
hash = "sha256-vM//1/qe9nKDwwlmFbqvBFqF8cCjIIdNKEtktyzBFB8=";
};
buildPhase = "pnpm build";
+1
View File
@@ -13,6 +13,7 @@
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-http": "^2.5.8",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "~2.4.2",
"clsx": "^2.1.1",
+50 -50
View File
@@ -3024,14 +3024,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl/openssl-0.10.79.crate",
"sha256": "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542",
"dest": "cargo/vendor/openssl-0.10.79"
"url": "https://static.crates.io/crates/openssl/openssl-0.10.80.crate",
"sha256": "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967",
"dest": "cargo/vendor/openssl-0.10.80"
},
{
"type": "inline",
"contents": "{\"package\": \"bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.79",
"contents": "{\"package\": \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\", \"files\": {}}",
"dest": "cargo/vendor/openssl-0.10.80",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -3063,14 +3063,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.115.crate",
"sha256": "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781",
"dest": "cargo/vendor/openssl-sys-0.9.115"
"url": "https://static.crates.io/crates/openssl-sys/openssl-sys-0.9.116.crate",
"sha256": "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4",
"dest": "cargo/vendor/openssl-sys-0.9.116"
},
{
"type": "inline",
"contents": "{\"package\": \"158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.115",
"contents": "{\"package\": \"f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4\", \"files\": {}}",
"dest": "cargo/vendor/openssl-sys-0.9.116",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4688,66 +4688,66 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri/tauri-2.11.1.crate",
"sha256": "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405",
"dest": "cargo/vendor/tauri-2.11.1"
"url": "https://static.crates.io/crates/tauri/tauri-2.11.2.crate",
"sha256": "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28",
"dest": "cargo/vendor/tauri-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405\", \"files\": {}}",
"dest": "cargo/vendor/tauri-2.11.1",
"contents": "{\"package\": \"437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28\", \"files\": {}}",
"dest": "cargo/vendor/tauri-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.1.crate",
"sha256": "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007",
"dest": "cargo/vendor/tauri-build-2.6.1"
"url": "https://static.crates.io/crates/tauri-build/tauri-build-2.6.2.crate",
"sha256": "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7",
"dest": "cargo/vendor/tauri-build-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007\", \"files\": {}}",
"dest": "cargo/vendor/tauri-build-2.6.1",
"contents": "{\"package\": \"4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7\", \"files\": {}}",
"dest": "cargo/vendor/tauri-build-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.1.crate",
"sha256": "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528",
"dest": "cargo/vendor/tauri-codegen-2.6.1"
"url": "https://static.crates.io/crates/tauri-codegen/tauri-codegen-2.6.2.crate",
"sha256": "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9",
"dest": "cargo/vendor/tauri-codegen-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528\", \"files\": {}}",
"dest": "cargo/vendor/tauri-codegen-2.6.1",
"contents": "{\"package\": \"e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-codegen-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.1.crate",
"sha256": "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502",
"dest": "cargo/vendor/tauri-macros-2.6.1"
"url": "https://static.crates.io/crates/tauri-macros/tauri-macros-2.6.2.crate",
"sha256": "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924",
"dest": "cargo/vendor/tauri-macros-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502\", \"files\": {}}",
"dest": "cargo/vendor/tauri-macros-2.6.1",
"contents": "{\"package\": \"ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924\", \"files\": {}}",
"dest": "cargo/vendor/tauri-macros-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.1.crate",
"sha256": "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee",
"dest": "cargo/vendor/tauri-plugin-2.6.1"
"url": "https://static.crates.io/crates/tauri-plugin/tauri-plugin-2.6.2.crate",
"sha256": "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e",
"dest": "cargo/vendor/tauri-plugin-2.6.2"
},
{
"type": "inline",
"contents": "{\"package\": \"eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-2.6.1",
"contents": "{\"package\": \"e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e\", \"files\": {}}",
"dest": "cargo/vendor/tauri-plugin-2.6.2",
"dest-filename": ".cargo-checksum.json"
},
{
@@ -4862,40 +4862,40 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.1.crate",
"sha256": "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc",
"dest": "cargo/vendor/tauri-runtime-2.11.1"
"url": "https://static.crates.io/crates/tauri-runtime/tauri-runtime-2.11.2.crate",
"sha256": "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef",
"dest": "cargo/vendor/tauri-runtime-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-2.11.1",
"contents": "{\"package\": \"48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.1.crate",
"sha256": "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1"
"url": "https://static.crates.io/crates/tauri-runtime-wry/tauri-runtime-wry-2.11.2.crate",
"sha256": "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2"
},
{
"type": "inline",
"contents": "{\"package\": \"a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.1",
"contents": "{\"package\": \"b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9\", \"files\": {}}",
"dest": "cargo/vendor/tauri-runtime-wry-2.11.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.1.crate",
"sha256": "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec",
"dest": "cargo/vendor/tauri-utils-2.9.1"
"url": "https://static.crates.io/crates/tauri-utils/tauri-utils-2.9.2.crate",
"sha256": "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95",
"dest": "cargo/vendor/tauri-utils-2.9.2"
},
{
"type": "inline",
"contents": "{\"package\": \"d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec\", \"files\": {}}",
"dest": "cargo/vendor/tauri-utils-2.9.1",
"contents": "{\"package\": \"092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95\", \"files\": {}}",
"dest": "cargo/vendor/tauri-utils-2.9.2",
"dest-filename": ".cargo-checksum.json"
},
{
+10
View File
@@ -17,6 +17,9 @@ importers:
'@tauri-apps/plugin-os':
specifier: ^2.3.2
version: 2.3.2
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-shell':
specifier: ^2.3.5
version: 2.3.5
@@ -289,6 +292,9 @@ packages:
'@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
@@ -763,6 +769,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.11.0
+21 -21
View File
@@ -2005,7 +2005,7 @@ dependencies = [
[[package]]
name = "moku"
version = "0.9.3"
version = "0.9.4"
dependencies = [
"dirs 5.0.1",
"reqwest 0.12.28",
@@ -2379,9 +2379,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.79"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
@@ -2410,9 +2410,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -3811,9 +3811,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@@ -3862,9 +3862,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [
"anyhow",
"cargo_toml",
@@ -3883,9 +3883,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -3910,9 +3910,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -3924,9 +3924,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [
"anyhow",
"glob",
@@ -4088,9 +4088,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [
"cookie",
"dpi",
@@ -4113,9 +4113,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
@@ -4139,9 +4139,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [
"anyhow",
"brotli",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "moku"
version = "0.9.3"
version = "0.9.4"
edition = "2021"
[lib]
+4 -1
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Moku",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"core:tray:default",
@@ -31,6 +33,7 @@
"core:window:allow-outer-position",
"core:window:allow-scale-factor",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
+4 -4
View File
@@ -3,7 +3,7 @@ use crate::ServerState;
use tauri::Manager;
#[tauri::command]
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
pub fn spawn_server(binary: String, web_ui_enabled: bool, app: tauri::AppHandle) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
@@ -22,10 +22,10 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
server::do_log(
&mut log,
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
&format!("[spawn_server] binary={:?} web_ui_enabled={} data_dir={:?}", binary, web_ui_enabled, data_dir),
);
server::conf::seed_server_conf(&data_dir);
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
let mut invocation =
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
@@ -77,4 +77,4 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
pub fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
server::kill_tachidesk(&app);
Ok(())
}
}
+75 -7
View File
@@ -58,14 +58,67 @@ pub fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
fn remove_dir_best_effort(path: &std::path::Path) {
if path.is_file() {
if let Err(e) = std::fs::remove_file(path) {
if e.raw_os_error() == Some(32) {
return;
}
}
} else if path.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
remove_dir_best_effort(&entry.path());
}
}
let _ = std::fs::remove_dir(path);
}
}
fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
let locked = if path.is_file() {
std::fs::OpenOptions::new().write(true).open(path).is_err()
} else if path.is_dir() {
std::fs::read_dir(path).is_err()
} else {
return true;
};
if !locked {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
#[tauri::command]
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
let window = app.get_webview_window("main").ok_or("no main window")?;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
// Note: We intentionally skip the WebView2 COM-level ClearBrowsingDataAll call here.
// The webview2_com crate pulls in a different version of windows_core than Tauri's
// own windows dependency, causing irreconcilable trait-impl conflicts at compile time.
// The filesystem cache removal below (app_cache_dir) is sufficient for our purposes;
// WebView2 will rebuild its cache on next launch from a clean directory.
window
.with_webview(move |_wv| {
let _ = tx.send(Ok(()));
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())??;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
wait_until_deletable(&cache_dir, 3);
remove_dir_best_effort(&cache_dir);
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -73,10 +126,17 @@ pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
pub fn clear_suwayomi_cache() -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
let data_dir = suwayomi_data_dir();
for dir in &["cache", "bin/kcef", "cache/kcef"] {
for dir in &["cache/kcef", "logs"] {
let p = data_dir.join(dir);
if p.exists() {
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
remove_dir_best_effort(&p);
}
}
for dir in &["downloads/thumbnails"] {
let p = data_dir.join(dir);
if p.exists() {
remove_dir_best_effort(&p);
let _ = std::fs::create_dir_all(&p);
}
}
Ok(())
@@ -87,10 +147,18 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
use crate::server::resolve::suwayomi_data_dir;
crate::server::kill_tachidesk(&app);
std::thread::sleep(std::time::Duration::from_millis(500));
let data_dir = suwayomi_data_dir();
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.exists() {
wait_until_deletable(&p, 10);
}
}
for entry_name in &targets {
let p = data_dir.join(entry_name);
if p.is_dir() {
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
+110 -2
View File
@@ -2,13 +2,81 @@ mod commands;
mod server;
use std::sync::Mutex;
use tauri::{Manager, WindowEvent};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WindowEvent,
};
use tauri_plugin_shell::process::CommandChild;
pub struct ServerState(pub Mutex<Option<CommandChild>>);
const IPC_PORT: u16 = 47823;
const HANDSHAKE: &[u8] = b"MOKU:1\n";
const FOCUS_CMD: &[u8] = b"focus\n";
fn do_quit(app: &tauri::AppHandle) {
server::kill_tachidesk(app);
app.exit(0);
}
fn start_instance_listener(app: tauri::AppHandle) {
std::thread::spawn(move || {
let Ok(listener) = TcpListener::bind(("127.0.0.1", IPC_PORT)) else {
return;
};
for stream in listener.incoming().flatten() {
handle_ipc_connection(stream, &app);
}
});
}
fn handle_ipc_connection(mut stream: TcpStream, app: &tauri::AppHandle) {
let mut buf = [0u8; 32];
let Ok(n) = stream.read(&mut buf) else { return };
let msg = &buf[..n];
if !msg.starts_with(HANDSHAKE) {
return;
}
let cmd = &msg[HANDSHAKE.len()..];
if cmd.starts_with(b"focus") {
let _ = stream.write_all(b"ok\n");
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
}
fn signal_existing_instance() -> bool {
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", IPC_PORT)) else {
return false;
};
stream.set_read_timeout(Some(std::time::Duration::from_millis(500))).ok();
let mut msg = Vec::new();
msg.extend_from_slice(HANDSHAKE);
msg.extend_from_slice(FOCUS_CMD);
if stream.write_all(&msg).is_err() {
return false;
}
let mut resp = [0u8; 4];
matches!(stream.read(&mut resp), Ok(n) if resp[..n].starts_with(b"ok"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
if signal_existing_instance() {
std::process::exit(0);
}
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_discord_rpc::init())
@@ -44,7 +112,47 @@ pub fn run() {
commands::biometric::windows_hello_authenticate,
commands::biometric::windows_hello_available,
])
.setup(|_app| Ok(()))
.setup(|app| {
start_instance_listener(app.handle().clone());
let show = MenuItem::with_id(app, "show", "Show Moku", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "Quit Moku", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep, &quit])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("Moku")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
"quit" => do_quit(app),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::Destroyed = event {
server::kill_tachidesk(window.app_handle());
+14 -5
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = true
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
pub fn seed_server_conf(data_dir: &PathBuf) {
pub fn seed_server_conf(data_dir: &PathBuf, web_ui_enabled: bool) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
let initial = patch_conf_key(
DEFAULT_SERVER_CONF.to_string(),
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
);
if let Err(e) = std::fs::write(&conf_path, initial) {
eprintln!("Could not write server.conf: {e}");
}
return;
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "true"),
patch_conf_key(
contents,
"server.webUIEnabled",
if web_ui_enabled { "true" } else { "false" },
),
"server.initialOpenInBrowserEnabled",
"false",
),
@@ -70,4 +79,4 @@ fn patch_conf_key(text: String, key: &str, value: &str) -> String {
out.push_str(&replacement);
out.push('\n');
out
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Moku",
"version": "0.9.3",
"version": "0.9.4",
"identifier": "io.github.MokuProject.Moku",
"build": {
"frontendDist": "../dist",
+22 -45
View File
@@ -3,9 +3,6 @@
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, updateSettings, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
@@ -48,8 +45,13 @@
}
async function doQuit() {
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
await win.destroy();
if (store.settings.autoStartServer) {
await Promise.race([
invoke("kill_server").catch(() => {}),
new Promise(res => setTimeout(res, 2000)),
]);
}
await invoke("exit_app");
}
async function doHide() {
@@ -89,6 +91,13 @@
return () => clearTimeout(timer);
});
$effect(() => {
if (!appReady) return;
downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(dlInterval);
});
$effect(() => {
if (store.settings.discordRpc) {
initRpc();
@@ -123,60 +132,28 @@
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);
await initStore();
startProbe();
if (store.settings.autoStartServer) {
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
invoke<void>("spawn_server", {
binary: store.settings.serverBinary,
webUiEnabled: store.settings.suwayomiWebUI ?? false,
}).catch((err: any) => {
if (err?.kind === "NotConfigured") boot.notConfigured = true;
else console.warn("Could not start server:", err);
});
}
await initStore();
startProbe();
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
"download-progress",
e => setActiveDownloads(e.payload),
);
await downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => {
stopProbe();
clearInterval(dlInterval);
unlistenResize();
unlistenScale();
unlistenDownload();
@@ -215,7 +192,7 @@
{/if}
<div id="app-shell" class="root">
{#if !store.activeChapter}<TitleBar />{/if}
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
<div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
+2 -2
View File
@@ -22,7 +22,7 @@ export const GET_SOURCES = `
sources {
nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest baseUrl
isConfigurable supportsLatest
extension { pkgName }
}
}
@@ -92,7 +92,7 @@ export const GET_MIGRATABLE_SOURCES = `
nodes {
sourceId
source {
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
}
}
}
+15 -17
View File
@@ -1,32 +1,30 @@
import type { Attachment } from "svelte/attachments";
/**
* {@attach selectPortal(triggerEl)}
*
* Moves the decorated element to <body> and positions it below `triggerEl`.
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
*
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
* the outside-click guard in Settings.svelte can exclude it from dismissal.
*/
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
return (menuEl: HTMLElement) => {
// Position & move to body
function position() {
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
const r = triggerEl.getBoundingClientRect();
const top = r.bottom / zoom + 4;
const right = r.right / zoom;
const width = menuEl.offsetWidth;
const left = Math.max(8, right - width);
menuEl.style.position = "fixed";
menuEl.style.top = `${r.bottom + 4}px`;
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
// clamp to viewport left edge
const left = parseFloat(menuEl.style.left);
if (left < 8) menuEl.style.left = "8px";
menuEl.style.top = `${top}px`;
menuEl.style.left = `${left}px`;
}
menuEl.style.visibility = "hidden";
document.body.appendChild(menuEl);
triggerEl.__selectMenuEl = menuEl;
position();
// Reposition on scroll / resize while open
requestAnimationFrame(() => {
position();
menuEl.style.visibility = "";
});
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
+7 -2
View File
@@ -5,8 +5,9 @@ import { uiAuth } from "@core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
@@ -34,7 +35,9 @@ function getAuthHeaders(): Record<string, string> {
async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob());
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
return blobUrl;
}
@@ -121,8 +124,10 @@ export function cancelQueuedFetches(): void {
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './memoryCache';
export * from './pageCache';
export * from './imageCache';
export * from './queryCache';
export * from './queryCache';
+44
View File
@@ -0,0 +1,44 @@
interface MemEntry<T> {
value: T;
expiresAt: number;
key: string;
}
export class MemoryCache<T> {
readonly #cap: number;
readonly #ttl: number;
readonly #map = new Map<string, MemEntry<T>>();
constructor(capacity: number, ttlMs: number) {
this.#cap = capacity;
this.#ttl = ttlMs;
}
get(key: string): T | undefined {
const entry = this.#map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
this.#map.delete(key);
this.#map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.#map.has(key)) this.#map.delete(key);
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
}
has(key: string): boolean {
const entry = this.#map.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
return true;
}
delete(key: string): void { this.#map.delete(key); }
clear(): void { this.#map.clear(); }
get size(): number { return this.#map.size; }
}
+1 -4
View File
@@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
}
export function preloadImage(url: string, useBlob: boolean): void {
if (useBlob) {
preloadBlobUrls([url], 0);
return;
}
if (useBlob) { preloadBlobUrls([url], 0); return; }
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
+74 -8
View File
@@ -1,11 +1,14 @@
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number;
fetcher?: () => Promise<T>;
ttl?: number;
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
const groups = new Map<string, Set<string>>();
const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
keyToGroups.get(key)!.add(tag);
}
}
function unregisterKey(key: string) {
const tags = keyToGroups.get(key);
if (tags) {
for (const tag of tags) groups.get(tag)?.delete(key);
keyToGroups.delete(key);
}
}
@@ -27,14 +40,20 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {});
return promise;
},
set<T>(key: string, value: T, group?: string | string[]) {
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
const existing = store.get(key) as Entry<T> | undefined;
store.set(key, {
promise: Promise.resolve(value),
fetchedAt: Date.now(),
fetcher: existing?.fetcher,
ttl: existing?.ttl,
});
registerGroups(key, group);
notify(key);
},
@@ -43,10 +62,38 @@ export const cache = {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() });
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
refresh<T>(key: string): Promise<T> | undefined {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing?.fetcher) return undefined;
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
return promise;
},
refreshGroup(tag: string): void {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
const existing = store.get(key);
if (existing?.fetcher) {
const promise = existing.fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
}
}
},
has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined {
@@ -54,18 +101,35 @@ export const cache = {
return e ? Date.now() - e.fetchedAt : undefined;
},
clear(key: string) { store.delete(key); notify(key); },
isStale(key: string): boolean {
const e = store.get(key);
if (!e) return true;
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
},
clear(key: string) {
unregisterKey(key);
store.delete(key);
notify(key);
},
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of keys) { store.delete(key); notify(key); }
for (const key of [...keys]) {
keyToGroups.get(key)?.delete(tag);
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
store.delete(key);
notify(key);
}
groups.delete(tag);
},
clearAll() {
const allKeys = [...store.keys()];
store.clear(); groups.clear();
store.clear();
groups.clear();
keyToGroups.clear();
allKeys.forEach(notify);
},
@@ -161,7 +225,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
}
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
cache.clear(CACHE_KEYS.MANGA(mangaId));
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA);
@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, GearSix, Swap } from "phosphor-svelte";
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { gql } from "@api/client";
@@ -29,14 +29,24 @@
let loading = $state(true);
let search = $state("");
type ContentFilter = "unread" | "downloaded";
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
let filterOpen = $state(false);
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
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
);
const filtered = $derived((() => {
let items = allManga;
const q = search.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
return items;
})());
let sourceNodes: SourceNode[] = $state([]);
@@ -56,6 +66,14 @@
}
}
function toggleFilter(f: ContentFilter) {
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
}
function clearFilters() {
activeFilters = {};
}
function openMigrate(group: SourceLibrary) {
const node = sourceNodes.find(s => s.id === group.sourceId);
migrateTarget = {
@@ -65,6 +83,20 @@
manga: group.manga,
};
}
$effect(() => {
if (!filterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
const CONTENT_FILTERS: [ContentFilter, string][] = [
["unread", "Unread"],
["downloaded", "Downloaded"],
];
</script>
<div class="root">
@@ -80,17 +112,56 @@
<span class="title">{extensionName}</span>
</div>
{#if !loading}
<span class="count-badge">{allManga.length}</span>
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${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 class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
</div>
<div class="filter-wrap">
<button
class="filter-btn"
class:filter-btn-active={hasActiveFilters}
title="Filter"
onclick={() => filterOpen = !filterOpen}
>
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterOpen}
<div class="filter-panel" role="menu">
<div class="panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each CONTENT_FILTERS as [f, label]}
<button
class="panel-item"
class:panel-item-active={activeFilters[f]}
role="menuitem"
onclick={() => toggleFilter(f)}
>
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
{#if sources.length > 0}
<button class="header-btn" onclick={onSettings} title="Extension settings">
<GearSix size={14} weight="bold" />
</button>
{/if}
</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">
@@ -181,6 +252,7 @@
: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-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.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); }
@@ -191,12 +263,31 @@
.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 { position: relative; display: flex; align-items: center; }
.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); }
.filter-wrap { position: relative; }
.filter-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); }
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.panel-clear-btn:hover { color: var(--color-error); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.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); }
@@ -237,4 +328,6 @@
.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); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+155 -57
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler";
@@ -20,6 +19,7 @@
tapToToggleBar: boolean;
pinchZoomEnabled: boolean;
chapterEpoch: number;
barPosition: "top" | "left" | "right";
onGetZoom: () => number;
onSetZoom: (z: number) => void;
resolveUrl: (url: string, priority?: number) => Promise<string>;
@@ -32,7 +32,7 @@
const {
style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom,
tapToToggleBar, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props();
@@ -214,20 +214,34 @@
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
let midScrollActive = $state(false);
let midScrollOriginY = 0;
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollOriginX = $state(0);
let midScrollCurrentY = 0;
let midScrollRaf: number | null = null;
function startMidScroll(originY: number) {
// Speed level 0-5 for the indicator bar
const midScrollSpeedLevel = $derived.by(() => {
if (!midScrollActive) return 0;
// recomputes when midScrollOriginY changes; actual dy read in RAF so this is just for display
return 0; // will be updated imperatively
});
let midScrollDisplayLevel = $state(0);
function startMidScroll(originY: number, originX: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollOriginX = originX;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const tick = () => {
if (!midScrollActive || !containerEl) return;
const dy = (window as any)._midScrollCurrentY - midScrollOriginY;
const dy = midScrollCurrentY - midScrollOriginY;
const deadZone = 24;
const speed = Math.sign(dy) * Math.max(0, Math.abs(dy) - deadZone) * 0.12;
const excess = Math.max(0, Math.abs(dy) - deadZone);
const speed = Math.sign(dy) * excess * 0.12;
containerEl.scrollTop += speed;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(tick);
};
midScrollRaf = requestAnimationFrame(tick);
@@ -235,6 +249,7 @@
function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
@@ -276,7 +291,11 @@
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
e.preventDefault();
if (midScrollActive) { stopMidScroll(); } else { startMidScroll(e.clientY); }
if (midScrollActive) { stopMidScroll(); } else {
// pause regular auto-scroll while mid-scroll is active
store.settings.autoScroll = false;
startMidScroll(e.clientY, e.clientX);
}
return;
}
if (style === "longstrip") {
@@ -299,7 +318,7 @@
}
export function onInspectMouseMove(e: MouseEvent) {
(window as any)._midScrollCurrentY = e.clientY;
midScrollCurrentY = e.clientY;
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
@@ -404,10 +423,6 @@
stopMidScroll();
}
});
$effect(() => {
(window as any)._midScrollCurrentY = 0;
});
</script>
<div
@@ -425,15 +440,33 @@
onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={midScrollActive ? "none" : style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; } }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => {
if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; return; }
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
}}
>
{#if midScrollActive}
<div class="midscroll-cursor" style="top:{midScrollOriginY}px"></div>
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#if loading}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true"><svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg></div>
</div>
{/if}
{#if error}
<div class="center-overlay"><p class="error-msg">{error}</p></div>
@@ -469,8 +502,8 @@
}}
/>
{:else}
<div class="strip-placeholder page-loader" aria-hidden="true">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<div class="strip-placeholder" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{/if}
</div>
@@ -481,7 +514,7 @@
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<div class="page-loader page-loader-single" aria-hidden="true">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
@@ -495,7 +528,7 @@
{#each currentGroup as pg, i (pg)}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
@@ -503,7 +536,11 @@
{/each}
</div>
{:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
</div>
{/if}
</div>
@@ -511,7 +548,7 @@
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<div class="page-loader page-loader-single" aria-hidden="true">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
@@ -523,7 +560,7 @@
</div>
<style>
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; }
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; }
.viewer.inspect-active { cursor: grab; overflow: hidden; }
@@ -545,29 +582,51 @@
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
display: flex;
align-items: stretch;
}
.page-loader {
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-raised) 90%, transparent);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(100vh - 80px);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong);
stroke-width: 0.8;
fill: none;
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
:global(.strip-gap) { margin-bottom: 8px; }
@@ -579,34 +638,73 @@
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.error-msg { color: var(--color-error); font-size: var(--text-base); }
.midscroll-cursor {
.midscroll-bar {
position: fixed;
left: 50%;
transform: translate(-50%, -50%);
top: 50%;
transform: translateY(-50%);
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.midscroll-origin-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: 1.5px solid var(--accent-fg);
opacity: 0.6;
flex-shrink: 0;
margin: 2px 0;
}
.midscroll-seg {
width: 4px;
height: 14px;
border-radius: 2px;
background: var(--border-strong);
transition: background 0.06s ease;
flex-shrink: 0;
}
.midscroll-seg-lit {
background: var(--accent-fg);
}
.midscroll-stop {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--accent-fg);
background: transparent;
pointer-events: none;
z-index: 100;
opacity: 0.85;
display: flex;
align-items: center;
justify-content: center;
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);
flex-shrink: 0;
}
.midscroll-cursor::before,
.midscroll-cursor::after {
content: "";
position: absolute;
left: 50%;
transform: translateX(-50%);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
}
.midscroll-cursor::before {
top: -10px;
border-bottom: 6px solid var(--accent-fg);
}
.midscroll-cursor::after {
bottom: -10px;
border-top: 6px solid var(--accent-fg);
.midscroll-stop:hover {
color: var(--text-primary);
background: var(--bg-overlay);
border-color: var(--border-strong);
}
</style>
@@ -593,6 +593,7 @@
fadingOut={readerState.fadingOut}
{tapToToggleBar}
{pinchZoomEnabled}
{barPosition}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
@@ -118,7 +118,6 @@
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:hidden={!uiVisible}
data-tauri-drag-region={barPosition === "top" ? true : undefined}
>
<div class="bar-start">
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
@@ -177,7 +176,7 @@
</button>
{#if !isVertical}
<span class="bar-sep"></span>
<span class="bar-sep" data-tauri-drag-region></span>
{/if}
</div>
@@ -187,6 +186,10 @@
</div>
{/if}
{#if !isVertical}
<div class="bar-drag-gap" data-tauri-drag-region></div>
{/if}
<div class="bar-end">
<div class="zoom-wrap">
<div class="zoom-inline">
@@ -393,12 +396,15 @@
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
.bar-drag-gap:active { cursor: grabbing; }
.bar-start, .bar-end {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.bar-top .bar-start { flex: 1; overflow: hidden; }
.bar-top .bar-start { overflow: hidden; }
.bar-left .bar-start,
.bar-left .bar-end,
.bar-right .bar-start,
@@ -51,6 +51,10 @@
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="s-toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Suwayomi Web UI</span><span class="s-desc">Enable the built-in Suwayomi web interface alongside Moku</span></div>
<button role="switch" aria-checked={store.settings.suwayomiWebUI ?? false} aria-label="Suwayomi Web UI" class="s-toggle" class:on={store.settings.suwayomiWebUI ?? false} onclick={() => updateSettings({ suwayomiWebUI: !(store.settings.suwayomiWebUI ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
@@ -13,17 +13,18 @@
import type { BackupEntry } from "@core/persistence/persist";
import { DEFAULT_SETTINGS } from "@types/settings";
import { DEFAULT_READING_STATS } from "@types/history";
import { clearBlobCache } from "@core/cache/imageCache";
import { clearPageCache } from "@core/cache/pageCache";
import { cache as queryCache } from "@core/cache/queryCache";
type ResetState = "idle" | "busy" | "done" | "error";
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
let resetItems = $state<ResetItem[]>([
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false },
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false },
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
{ key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false },
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
]);
let confirming = $state<string | null>(null);
@@ -73,19 +74,24 @@
});
}
async function clearAllCaches(): Promise<void> {
clearBlobCache();
clearPageCache();
queryCache.clearAll();
await Promise.all([
invoke("clear_moku_cache"),
invoke("clear_suwayomi_cache"),
gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
]);
}
async function runReset(key: string) {
confirming = null;
patchReset(key, { state: "busy", error: null });
try {
switch (key) {
case "moku-cache":
await invoke("clear_moku_cache");
break;
case "suwayomi-cache":
await invoke("clear_suwayomi_cache");
break;
case "server-cache":
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
case "all-cache":
await clearAllCaches();
break;
case "reading-history":
store.clearHistory();
+4 -2
View File
@@ -3,6 +3,8 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
const { onClose }: { onClose: () => void } = $props();
const win = getCurrentWindow();
const os = platform();
const isMac = os === "macos";
@@ -31,7 +33,7 @@
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
</button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
@@ -50,7 +52,7 @@
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+66 -43
View File
@@ -4,7 +4,8 @@ import { trackingState } from "@features/tracking/store/tracki
import { loadAllStores } from "@core/persistence/persist";
import { notifyReauthSuccess } from "@api/client";
const MAX_ATTEMPTS = 40;
const MAX_ATTEMPTS = 15;
const BG_MAX_ATTEMPTS = 60;
export const boot = $state({
serverProbeOk: false,
@@ -26,6 +27,44 @@ export async function initStore() {
store.hydrate(saved);
}
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return;
boot.serverProbeOk = true;
boot.failed = false;
boot.skipped = false;
trackingState.bootSync().catch(() => {});
}
function handleAuthRequired(gen: number) {
if (gen !== probeGeneration) return;
boot.serverProbeOk = true;
boot.failed = false;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
loginBasic(user, pass)
.then(() => { if (gen === probeGeneration) trackingState.bootSync().catch(() => {}); })
.catch(() => {
if (gen !== probeGeneration) return;
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
});
return;
}
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
if (mode === "UI_LOGIN") {
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
trackingState.bootSync().catch(() => {});
}
export function startProbe() {
const gen = ++probeGeneration;
boot.failed = false;
@@ -36,51 +75,36 @@ export function startProbe() {
async function probe() {
if (gen !== probeGeneration) return;
tries++;
const result = await probeServer();
if (gen !== probeGeneration) return;
if (result === "ok") {
boot.serverProbeOk = true;
trackingState.bootSync().catch(() => {});
return;
}
if (result === "ok") { handleProbeSuccess(gen); return; }
if (result === "auth_required") { handleAuthRequired(gen); return; }
if (tries >= MAX_ATTEMPTS) { boot.failed = true; startBackgroundProbe(gen); return; }
if (result === "auth_required") {
boot.serverProbeOk = true;
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const user = store.settings.serverAuthUser?.trim() ?? "";
const pass = store.settings.serverAuthPass?.trim() ?? "";
if (user && pass) {
try {
await loginBasic(user, pass);
if (gen !== probeGeneration) return;
trackingState.bootSync().catch(() => {});
return;
} catch {}
}
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
if (mode === "UI_LOGIN") {
boot.loginUser = store.settings.serverAuthUser ?? "";
boot.loginRequired = true;
return;
}
trackingState.bootSync().catch(() => {});
return;
}
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
setTimeout(probe, Math.min(750 + tries * 250, 3000));
setTimeout(probe, Math.min(300 + tries * 150, 1500));
}
setTimeout(probe, 2000);
setTimeout(probe, 100);
}
function startBackgroundProbe(gen: number) {
let bgTries = 0;
async function bgProbe() {
if (gen !== probeGeneration) return;
bgTries++;
const result = await probeServer();
if (gen !== probeGeneration) return;
if (result === "ok") { handleProbeSuccess(gen); return; }
if (result === "auth_required") { handleAuthRequired(gen); return; }
if (bgTries >= BG_MAX_ATTEMPTS) return;
setTimeout(bgProbe, 2000);
}
setTimeout(bgProbe, 2000);
}
export function stopProbe() {
@@ -94,7 +118,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
}
boot.loginBusy = true;
boot.loginError = null;
try {
const mode = store.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
@@ -102,7 +125,6 @@ export async function submitLogin(onSuccess: () => void): Promise<void> {
} else {
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim());
}
boot.loginRequired = false;
boot.sessionExpired = false;
boot.skipped = false;
@@ -128,10 +150,11 @@ export function retryBoot() {
}
export function bypassBoot(onReady: () => void) {
probeGeneration++;
const gen = probeGeneration;
boot.serverProbeOk = true;
boot.loginRequired = false;
boot.sessionExpired = false;
boot.skipped = true;
onReady();
startBackgroundProbe(gen);
}
+2 -2
View File
@@ -92,7 +92,7 @@ export interface Settings {
discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
serverUrl: string; serverBinary: string; autoStartServer: boolean;
serverUrl: string; serverBinary: string; autoStartServer: boolean; suwayomiWebUI: boolean;
preferredExtensionLang: string; keybinds: Keybinds;
idleTimeoutMin?: number; splashCards?: boolean;
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
@@ -143,7 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
discordRpc: false,
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true, suwayomiWebUI: false,
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,