mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da788e90ba | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| bee8117aac | |||
| 0bea9c22cb |
@@ -1,11 +1,15 @@
|
|||||||
# --- Build Artifacts ---
|
# --- Build Artifacts ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
suwayomi-raw/
|
||||||
|
suwayomi-windows.zip
|
||||||
|
suwayomi.zip
|
||||||
dist/
|
dist/
|
||||||
dist-tauri/
|
dist-tauri/
|
||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
|
||||||
# --- Nix ---
|
# --- Nix ---
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
@@ -32,6 +36,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# --- Tauri specific ---
|
# --- Tauri specific ---
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
|
|||||||
@@ -13,27 +13,46 @@ depends=(
|
|||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
'rust'
|
'rust'
|
||||||
'cargo'
|
|
||||||
'nodejs'
|
|
||||||
'pnpm'
|
'pnpm'
|
||||||
)
|
)
|
||||||
|
optdepends=(
|
||||||
|
'discord: Discord rich presence'
|
||||||
|
)
|
||||||
|
options=('!strip')
|
||||||
source=(
|
source=(
|
||||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||||
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||||
)
|
)
|
||||||
|
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||||
sha256sums=(
|
sha256sums=(
|
||||||
'4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684'
|
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||||
)
|
)
|
||||||
|
b2sums=(
|
||||||
|
'SKIP'
|
||||||
|
'SKIP'
|
||||||
|
)
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
sed -i 's/^lto\s*=\s*true/lto = "thin"/' src-tauri/Cargo.toml
|
||||||
|
mkdir -p src-tauri/.cargo
|
||||||
|
cat > src-tauri/.cargo/config.toml << 'EOF'
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "Moku-$pkgver"
|
cd "Moku-$pkgver"
|
||||||
pnpm build
|
pnpm build
|
||||||
|
local fixed_cflags="${CFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cflags="${fixed_cflags/-flto=auto/}"
|
||||||
|
local fixed_cxxflags="${CXXFLAGS/-march=native/-march=x86-64}"
|
||||||
|
fixed_cxxflags="${fixed_cxxflags/-flto=auto/}"
|
||||||
|
CFLAGS="$fixed_cflags" \
|
||||||
|
CXXFLAGS="$fixed_cxxflags" \
|
||||||
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
TAURI_SKIP_DEVSERVER_CHECK=true cargo build \
|
||||||
--release \
|
--release \
|
||||||
--manifest-path src-tauri/Cargo.toml
|
--manifest-path src-tauri/Cargo.toml
|
||||||
@@ -52,7 +71,7 @@ package() {
|
|||||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||||
server.ip = "127.0.0.1"
|
server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = true
|
server.webUIEnabled = false
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -68,14 +87,14 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
|||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /usr/lib/moku/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
@@ -87,12 +106,12 @@ export _JAVA_OPTIONS="-Djava.awt.headless=true"
|
|||||||
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
|
||||||
|
|
||||||
exec java \
|
exec java \
|
||||||
-Djava.awt.headless=true \
|
-Djava.awt.headless=true \
|
||||||
-Dapple.awt.UIElement=true \
|
-Dapple.awt.UIElement=true \
|
||||||
-Dsun.java2d.noddraw=true \
|
-Dsun.java2d.noddraw=true \
|
||||||
-Dsun.awt.disablegui=true \
|
-Dsun.awt.disablegui=true \
|
||||||
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
|
||||||
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
|
||||||
LAUNCHER
|
LAUNCHER
|
||||||
|
|
||||||
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
install -Dm644 packaging/io.github.moku_project.Moku.desktop \
|
||||||
@@ -105,6 +124,6 @@ LAUNCHER
|
|||||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||||
|
install -Dm644 LICENSE \
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,77 @@ build-options:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
|
- name: intltool
|
||||||
|
buildsystem: autotools
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||||
|
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||||
|
|
||||||
|
- name: libdbusmenu
|
||||||
|
buildsystem: autotools
|
||||||
|
build-options:
|
||||||
|
cflags: -Wno-error
|
||||||
|
env:
|
||||||
|
HAVE_VALGRIND_FALSE: '#'
|
||||||
|
HAVE_VALGRIND_TRUE: ''
|
||||||
|
config-opts:
|
||||||
|
- --with-gtk=3
|
||||||
|
- --disable-static
|
||||||
|
- --disable-dumper
|
||||||
|
- --disable-tests
|
||||||
|
- --disable-gtk-doc
|
||||||
|
- --disable-vala
|
||||||
|
- --disable-introspection
|
||||||
|
cleanup:
|
||||||
|
- /include
|
||||||
|
- /libexec
|
||||||
|
- /lib/pkgconfig
|
||||||
|
- /lib/*.la
|
||||||
|
- /share/doc
|
||||||
|
- /share/libdbusmenu
|
||||||
|
- /share/gtk-doc
|
||||||
|
- /share/gir-1.0
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||||
|
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||||
|
|
||||||
|
- name: libayatana-ido
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||||
|
tag: 0.10.3
|
||||||
|
|
||||||
|
- name: libayatana-indicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||||
|
tag: 0.9.4
|
||||||
|
|
||||||
|
- name: libayatana-appindicator
|
||||||
|
buildsystem: cmake-ninja
|
||||||
|
config-opts:
|
||||||
|
- -DENABLE_TESTS=OFF
|
||||||
|
- -DENABLE_BINDINGS_MONO=OFF
|
||||||
|
- -DENABLE_BINDINGS_VALA=OFF
|
||||||
|
- -DGSETTINGS_COMPILE=OFF
|
||||||
|
sources:
|
||||||
|
- type: git
|
||||||
|
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||||
|
tag: 0.5.93
|
||||||
|
- type: shell
|
||||||
|
commands:
|
||||||
|
- sed -i '/add_subdirectory(docs)/d' CMakeLists.txt
|
||||||
|
|
||||||
- name: openjdk
|
- name: openjdk
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
@@ -52,9 +123,6 @@ modules:
|
|||||||
- type: inline
|
- type: inline
|
||||||
dest-filename: catch_abort.c
|
dest-filename: catch_abort.c
|
||||||
contents: |
|
contents: |
|
||||||
// Linux only:
|
|
||||||
// Attempts to catch SIGTRAP and exit the thread instead of bringing down the whole process
|
|
||||||
|
|
||||||
#define _GNU_SOURCE
|
#define _GNU_SOURCE
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
@@ -117,19 +185,16 @@ modules:
|
|||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
# Seed conf on first run
|
|
||||||
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
if [ ! -f "$DATA_DIR/server.conf" ]; then
|
||||||
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
cp /app/tachidesk/default-conf/server.conf "$DATA_DIR/server.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Force-patch the three keys that cause JCEF/GUI crashes every launch.
|
|
||||||
sed -i \
|
sed -i \
|
||||||
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
|
||||||
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
|
||||||
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
|
||||||
"$DATA_DIR/server.conf"
|
"$DATA_DIR/server.conf"
|
||||||
|
|
||||||
# Append keys if absent
|
|
||||||
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.webUIEnabled' "$DATA_DIR/server.conf" || echo 'server.webUIEnabled = true' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.initialOpenInBrowserEnabled' "$DATA_DIR/server.conf" || echo 'server.initialOpenInBrowserEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
grep -q 'server\.systemTrayEnabled' "$DATA_DIR/server.conf" || echo 'server.systemTrayEnabled = false' >> "$DATA_DIR/server.conf"
|
||||||
@@ -166,10 +231,10 @@ modules:
|
|||||||
CARGO_HOME: /run/build/moku/cargo
|
CARGO_HOME: /run/build/moku/cargo
|
||||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
|
||||||
build-commands:
|
build-commands:
|
||||||
- tar -xzf frontend-dist.tar.gz
|
- tar -xzf frontend-dist.tar.gz
|
||||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||||
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
- install -Dm644 packaging/io.github.moku_project.Moku.desktop /app/share/applications/io.github.moku_project.Moku.desktop
|
||||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/io.github.moku_project.Moku.png
|
||||||
@@ -180,7 +245,7 @@ modules:
|
|||||||
- type: git
|
- type: git
|
||||||
url: https://github.com/moku-project/Moku.git
|
url: https://github.com/moku-project/Moku.git
|
||||||
tag: v0.9.4
|
tag: v0.9.4
|
||||||
commit: 9f8bf6ffc11e0808acc735132e1aeff8b3bf1e09
|
commit: 239960683b6c7f1347e1798b0e179a8a46628728
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
sha256: 7db288b4b54277aa82b6ec5b21fc31a1e71f8246c50a74777500083b806c1fa5
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ use crate::ServerState;
|
|||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
|
pub fn spawn_server(
|
||||||
|
binary: String,
|
||||||
|
binary_args: Option<String>,
|
||||||
|
web_ui_enabled: bool,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<(), SpawnError> {
|
||||||
{
|
{
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
if state.0.lock().unwrap().is_some() {
|
if state.0.lock().unwrap().is_some() {
|
||||||
@@ -20,12 +25,17 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
|
|||||||
.open(&log_path)
|
.open(&log_path)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
|
let binary_args = binary_args.unwrap_or_default();
|
||||||
|
|
||||||
server::do_log(
|
server::do_log(
|
||||||
&mut log,
|
&mut log,
|
||||||
&format!("[spawn_server] binary={:?} data_dir={:?}", binary, data_dir),
|
&format!(
|
||||||
|
"[spawn_server] binary={:?} binary_args={:?} web_ui_enabled={} data_dir={:?}",
|
||||||
|
binary, binary_args, web_ui_enabled, data_dir
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
server::conf::seed_server_conf(&data_dir);
|
server::conf::seed_server_conf(&data_dir, web_ui_enabled);
|
||||||
|
|
||||||
let mut invocation =
|
let mut invocation =
|
||||||
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
server::resolve::resolve_server_binary(&binary, &app, &mut log).map_err(|e| {
|
||||||
@@ -33,6 +43,13 @@ pub fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnEr
|
|||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if !binary_args.trim().is_empty() {
|
||||||
|
let extra: Vec<String> = binary_args.split_whitespace().map(|s| s.to_string()).collect();
|
||||||
|
let mut merged = extra;
|
||||||
|
merged.extend(invocation.args);
|
||||||
|
invocation.args = merged;
|
||||||
|
}
|
||||||
|
|
||||||
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
if invocation.bin.ends_with("java") || invocation.bin.ends_with("java.exe") {
|
||||||
let rootdir_flag = format!(
|
let rootdir_flag = format!(
|
||||||
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use crate::server::resolve::strip_unc;
|
use crate::server::resolve::strip_unc;
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
@@ -53,6 +52,34 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
.map(|p| p.to_string())
|
.map(|p| p.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_server_binary(app: tauri::AppHandle) -> Option<String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable", &["exe", "jar", "bat", "cmd"]);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable or JAR", &["jar", "command", "sh", "app"]);
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("Choose Server Binary")
|
||||||
|
.add_filter("Executable or JAR", &["jar", "sh"]);
|
||||||
|
|
||||||
|
dialog.blocking_pick_file().map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn exit_app(app: tauri::AppHandle) {
|
pub fn exit_app(app: tauri::AppHandle) {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
@@ -99,11 +126,6 @@ pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
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
|
window
|
||||||
.with_webview(move |_wv| {
|
.with_webview(move |_wv| {
|
||||||
let _ = tx.send(Ok(()));
|
let _ = tx.send(Ok(()));
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ pub fn run() {
|
|||||||
commands::system::reset_suwayomi_data,
|
commands::system::reset_suwayomi_data,
|
||||||
commands::system::open_path,
|
commands::system::open_path,
|
||||||
commands::system::pick_downloads_folder,
|
commands::system::pick_downloads_folder,
|
||||||
|
commands::system::pick_server_binary,
|
||||||
commands::backup::export_app_data,
|
commands::backup::export_app_data,
|
||||||
commands::backup::import_app_data,
|
commands::backup::import_app_data,
|
||||||
commands::backup::auto_backup_app_data,
|
commands::backup::auto_backup_app_data,
|
||||||
@@ -159,5 +160,5 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running moku");
|
.expect("error while running moku")
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
|
||||||
server.port = 4567
|
server.port = 4567
|
||||||
server.webUIEnabled = true
|
server.webUIEnabled = false
|
||||||
server.initialOpenInBrowserEnabled = false
|
server.initialOpenInBrowserEnabled = false
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
server.webUIInterface = "browser"
|
server.webUIInterface = "browser"
|
||||||
@@ -17,7 +17,7 @@ server.maxSourcesInParallel = 6
|
|||||||
server.extensionRepos = []
|
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");
|
let conf_path = data_dir.join("server.conf");
|
||||||
|
|
||||||
if !conf_path.exists() {
|
if !conf_path.exists() {
|
||||||
@@ -25,7 +25,12 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
|||||||
eprintln!("Could not create Suwayomi data dir: {e}");
|
eprintln!("Could not create Suwayomi data dir: {e}");
|
||||||
return;
|
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}");
|
eprintln!("Could not write server.conf: {e}");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +42,11 @@ pub fn seed_server_conf(data_dir: &PathBuf) {
|
|||||||
|
|
||||||
let patched = patch_conf_key(
|
let patched = patch_conf_key(
|
||||||
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",
|
"server.initialOpenInBrowserEnabled",
|
||||||
"false",
|
"false",
|
||||||
),
|
),
|
||||||
|
|||||||
+76
-175
@@ -1,7 +1,6 @@
|
|||||||
use crate::server::do_log;
|
use crate::server::do_log;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use walkdir::WalkDir;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
@@ -48,22 +47,14 @@ pub fn strip_unc(path: PathBuf) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
fn java_bin_name() -> &'static str {
|
||||||
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
if cfg!(target_os = "windows") { "java.exe" } else { "java" }
|
||||||
#[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(
|
fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option<std::fs::File>) -> Option<PathBuf> {
|
||||||
log,
|
let java = bundle_dir.join("jre").join("bin").join(java_bin_name());
|
||||||
&format!("[find_java] path: {:?} exists: {}", java, java.exists()),
|
do_log(log, &format!("[find_java] {:?} exists={}", java, java.exists()));
|
||||||
);
|
if java.exists() { Some(java) } else { None }
|
||||||
if java.exists() {
|
|
||||||
Some(java)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_root_args() -> Vec<String> {
|
fn data_root_args() -> Vec<String> {
|
||||||
@@ -74,24 +65,34 @@ fn jar_data_root_flag() -> String {
|
|||||||
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
|
format!("-Dsuwayomi.server.rootDir={}", suwayomi_data_dir().to_string_lossy())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn jar_invocation(java: PathBuf, jar: PathBuf, working_dir: PathBuf) -> ServerInvocation {
|
||||||
|
ServerInvocation {
|
||||||
|
bin: java.to_string_lossy().into_owned(),
|
||||||
|
args: vec![
|
||||||
|
jar_data_root_flag(),
|
||||||
|
"-jar".to_string(),
|
||||||
|
jar.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
working_dir: Some(working_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_server_binary(
|
pub fn resolve_server_binary(
|
||||||
binary: &str,
|
binary: &str,
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
log: &mut Option<std::fs::File>,
|
log: &mut Option<std::fs::File>,
|
||||||
) -> Result<ServerInvocation, SpawnError> {
|
) -> Result<ServerInvocation, SpawnError> {
|
||||||
do_log(log, &format!("[resolve] binary = {:?}", binary));
|
do_log(log, &format!("[resolve] binary={:?}", binary));
|
||||||
|
|
||||||
if !binary.trim().is_empty() {
|
if !binary.trim().is_empty() {
|
||||||
let path = strip_unc(PathBuf::from(binary.trim()));
|
let path = strip_unc(PathBuf::from(binary.trim()));
|
||||||
do_log(
|
do_log(log, &format!("[resolve] user path={:?} exists={}", path, path.exists()));
|
||||||
log,
|
|
||||||
&format!("[resolve] user path: {:?} exists={}", path, path.exists()),
|
|
||||||
);
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
|
let working_dir = path.parent().map(|p| p.to_path_buf());
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: path.to_string_lossy().into_owned(),
|
bin: path.to_string_lossy().into_owned(),
|
||||||
args: data_root_args(),
|
args: data_root_args(),
|
||||||
working_dir: path.parent().map(|p| p.to_path_buf()),
|
working_dir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
do_log(log, "[resolve] user path not found, falling through");
|
do_log(log, "[resolve] user path not found, falling through");
|
||||||
@@ -101,10 +102,7 @@ pub fn resolve_server_binary(
|
|||||||
if let Some(bin_dir) = exe.parent() {
|
if let Some(bin_dir) = exe.parent() {
|
||||||
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
for name in &["tachidesk-server", "suwayomi-launcher"] {
|
||||||
let p = bin_dir.join(name);
|
let p = bin_dir.join(name);
|
||||||
do_log(
|
do_log(log, &format!("[resolve] sibling={:?} exists={}", p, p.exists()));
|
||||||
log,
|
|
||||||
&format!("[resolve] sibling: {:?} exists={}", p, p.exists()),
|
|
||||||
);
|
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: p.to_string_lossy().into_owned(),
|
bin: p.to_string_lossy().into_owned(),
|
||||||
@@ -116,54 +114,31 @@ pub fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
|
let resource_dir = {
|
||||||
|
let raw = app.path().resource_dir().unwrap_or_default();
|
||||||
|
let stripped = strip_unc(raw);
|
||||||
|
do_log(log, &format!("[resolve] resource_dir={:?}", stripped));
|
||||||
|
stripped
|
||||||
|
};
|
||||||
|
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
|
|
||||||
do_log(
|
do_log(log, &format!("[resolve] bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
log,
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
&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) {
|
if let Some(java) = find_java_in_bundle(&bundle_dir, log) {
|
||||||
Some(java) if jar.exists() => {
|
if jar.exists() {
|
||||||
do_log(log, "[resolve] using bundled JRE");
|
do_log(log, "[resolve] using bundled JRE + jar");
|
||||||
return Ok(ServerInvocation {
|
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||||
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 &[
|
for name in &["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"] {
|
||||||
"suwayomi-launcher",
|
|
||||||
"suwayomi-launcher.sh",
|
|
||||||
"tachidesk-server",
|
|
||||||
] {
|
|
||||||
let p = resource_dir.join(name);
|
let p = resource_dir.join(name);
|
||||||
do_log(
|
do_log(log, &format!("[resolve] sidecar={:?} exists={}", p, p.exists()));
|
||||||
log,
|
|
||||||
&format!("[resolve] sidecar: {:?} exists={}", p, p.exists()),
|
|
||||||
);
|
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: p.to_string_lossy().into_owned(),
|
bin: p.to_string_lossy().into_owned(),
|
||||||
@@ -174,26 +149,16 @@ pub fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
if let Some(java) = find_java_in_bundle(&resource_dir, log) {
|
||||||
let jar = std::fs::read_dir(&resource_dir).ok().and_then(|mut rd| {
|
let jar = std::fs::read_dir(&resource_dir)
|
||||||
rd.find(|e| {
|
.ok()
|
||||||
e.as_ref()
|
.and_then(|mut rd| {
|
||||||
.map(|e| e.file_name().to_string_lossy().ends_with(".jar"))
|
rd.find(|e| e.as_ref().map(|e| e.file_name().to_string_lossy().ends_with(".jar")).unwrap_or(false))
|
||||||
.unwrap_or(false)
|
.and_then(|e| e.ok())
|
||||||
})
|
.map(|e| e.path())
|
||||||
.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),
|
|
||||||
});
|
});
|
||||||
|
if let Some(jar_path) = jar {
|
||||||
|
do_log(log, &format!("[resolve] generic JRE java={:?} jar={:?}", java, jar_path));
|
||||||
|
return Ok(jar_invocation(java, jar_path, resource_dir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,108 +166,43 @@ pub fn resolve_server_binary(
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
let resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let contents_dir = resource_dir.parent().unwrap_or(&resource_dir).to_path_buf();
|
let bundle_dir = resource_dir.join("suwayomi-bundle");
|
||||||
|
|
||||||
do_log(
|
do_log(log, &format!("[resolve] macOS resource_dir={:?}", resource_dir));
|
||||||
log,
|
do_log(log, &format!("[resolve] macOS bundle_dir={:?} exists={}", bundle_dir, bundle_dir.exists()));
|
||||||
&format!("[resolve] macOS contents_dir = {:?}", contents_dir),
|
|
||||||
);
|
|
||||||
|
|
||||||
const NATIVE_NAMES: &[&str] = &[
|
let java = bundle_dir.join("jre").join("bin").join("java");
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
let launcher_sh = bundle_dir.join("Suwayomi Launcher.command");
|
||||||
"suwayomi-server",
|
let launcher_jar = bundle_dir.join("Suwayomi-Launcher.jar");
|
||||||
"suwayomi-launcher",
|
|
||||||
"suwayomi-launcher.sh",
|
|
||||||
"tachidesk-server",
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut found_binary: Option<ServerInvocation> = None;
|
do_log(log, &format!("[resolve] java={:?} exists={}", java, java.exists()));
|
||||||
let mut found_java: Option<(PathBuf, PathBuf)> = None;
|
do_log(log, &format!("[resolve] jar={:?} exists={}", jar, jar.exists()));
|
||||||
|
do_log(log, &format!("[resolve] launcher.command={:?} exists={}", launcher_sh, launcher_sh.exists()));
|
||||||
|
do_log(log, &format!("[resolve] launcher.jar={:?} exists={}", launcher_jar, launcher_jar.exists()));
|
||||||
|
|
||||||
'outer: for depth in 0u8..=8 {
|
if java.exists() && jar.exists() {
|
||||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Server.jar");
|
||||||
.min_depth(depth as usize)
|
return Ok(jar_invocation(java, jar, bundle_dir));
|
||||||
.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 {
|
if launcher_sh.exists() {
|
||||||
return Ok(inv);
|
use std::os::unix::fs::PermissionsExt;
|
||||||
}
|
let _ = std::fs::set_permissions(&launcher_sh, std::fs::Permissions::from_mode(0o755));
|
||||||
|
do_log(log, "[resolve] macOS using Suwayomi Launcher.command");
|
||||||
if let Some((java, jar)) = found_java {
|
|
||||||
let working_dir = jar.parent().map(|p| p.to_path_buf());
|
|
||||||
return Ok(ServerInvocation {
|
return Ok(ServerInvocation {
|
||||||
bin: java.to_string_lossy().into_owned(),
|
bin: launcher_sh.to_string_lossy().into_owned(),
|
||||||
args: vec![jar_data_root_flag(), "-jar".to_string(), jar.to_string_lossy().into_owned()],
|
args: vec![],
|
||||||
working_dir,
|
working_dir: Some(bundle_dir),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
do_log(log, "[resolve] macOS scan found nothing in bundle");
|
if java.exists() && launcher_jar.exists() {
|
||||||
|
do_log(log, "[resolve] macOS using bundled JRE + Suwayomi-Launcher.jar");
|
||||||
|
return Ok(jar_invocation(java, launcher_jar, bundle_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
do_log(log, "[resolve] macOS bundle not found, falling through to PATH");
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
@@ -314,6 +214,7 @@ pub fn resolve_server_binary(
|
|||||||
.filter(|o| o.status.success())
|
.filter(|o| o.status.success())
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()));
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let resolved = std::process::Command::new("which")
|
let resolved = std::process::Command::new("which")
|
||||||
.arg(name)
|
.arg(name)
|
||||||
|
|||||||
+4
-1
@@ -138,7 +138,10 @@
|
|||||||
startProbe();
|
startProbe();
|
||||||
|
|
||||||
if (store.settings.autoStartServer) {
|
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;
|
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||||
else console.warn("Could not start server:", err);
|
else console.warn("Could not start server:", err);
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-2
@@ -1,5 +1,5 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { fetchAuthenticated, AuthRequiredError, uiAuth } from "../core/auth";
|
import { fetchAuthenticated, AuthRequiredError, refreshUiAccessToken } from "../core/auth";
|
||||||
import { boot } from "@store/boot.svelte";
|
import { boot } from "@store/boot.svelte";
|
||||||
import { getBlobUrl } from "@core/cache/imageCache";
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
@@ -104,6 +104,15 @@ export async function gql<T>(
|
|||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const tryRefreshAndRetry = async (): Promise<T | null> => {
|
||||||
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode !== "UI_LOGIN" || boot.skipped) return null;
|
||||||
|
const refreshed = await refreshUiAccessToken(true);
|
||||||
|
if (!refreshed) return null;
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return attempt();
|
||||||
|
};
|
||||||
|
|
||||||
const attempt = async (): Promise<T> => {
|
const attempt = async (): Promise<T> => {
|
||||||
const res = await fetchWithRetry(
|
const res = await fetchWithRetry(
|
||||||
`${getServerUrl()}/api/graphql`,
|
`${getServerUrl()}/api/graphql`,
|
||||||
@@ -111,12 +120,21 @@ export async function gql<T>(
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const retried = await tryRefreshAndRetry();
|
||||||
|
if (retried) return retried;
|
||||||
|
}
|
||||||
|
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
}
|
||||||
const json: GQLResponse<T> = await res.json();
|
const json: GQLResponse<T> = await res.json();
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (json.errors?.length) {
|
if (json.errors?.length) {
|
||||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||||
if (isAuthError && !boot.skipped) {
|
if (isAuthError && !boot.skipped) {
|
||||||
|
const retried = await tryRefreshAndRetry();
|
||||||
|
if (retried) return retried;
|
||||||
|
|
||||||
boot.sessionExpired = true;
|
boot.sessionExpired = true;
|
||||||
boot.loginRequired = true;
|
boot.loginRequired = true;
|
||||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||||
|
|||||||
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const LOGIN_USER = `
|
export const LOGIN_USER = `
|
||||||
mutation Login($username: String!, $password: String!) {
|
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||||
login(input: { username: $username, password: $password }) {
|
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||||
accessToken
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const REFRESH_TOKEN = `
|
export const REFRESH_TOKEN = `
|
||||||
mutation RefreshToken {
|
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
refreshToken(input: {}) { accessToken }
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
+494
-16
@@ -10,19 +10,282 @@ export class AuthRequiredError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = "moku_access_token";
|
const TOKEN_KEY = "moku_access_token";
|
||||||
let _accessToken: string | null = sessionStorage.getItem(TOKEN_KEY);
|
const UI_SESSION_KEY = "moku_ui_auth_session";
|
||||||
|
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||||
|
const AUTH_DEBUG = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
||||||
|
|
||||||
|
interface StoredAccessToken {
|
||||||
|
base: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredUiAuthSession {
|
||||||
|
base: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientMutationId?: string;
|
||||||
|
accessExpiresAt?: number | null;
|
||||||
|
refreshExpiresAt?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtSettings {
|
||||||
|
jwtAudience?: string | null;
|
||||||
|
jwtRefreshExpiry?: string | null;
|
||||||
|
jwtTokenExpiry?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiAuthDebugStatus {
|
||||||
|
mode: AuthMode;
|
||||||
|
serverBase: string;
|
||||||
|
hasSession: boolean;
|
||||||
|
hasRefreshToken: boolean;
|
||||||
|
accessExpiresAt: number | null;
|
||||||
|
refreshExpiresAt: number | null;
|
||||||
|
accessExpiresInMs: number | null;
|
||||||
|
refreshExpiresInMs: number | null;
|
||||||
|
shouldRefreshSoon: boolean;
|
||||||
|
refreshInFlight: boolean;
|
||||||
|
skewMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _accessToken: string | null = null;
|
||||||
|
let _accessTokenBase: string | null = null;
|
||||||
|
let _uiSession: StoredUiAuthSession | null = null;
|
||||||
|
let _refreshPromise: Promise<string | null> | null = null;
|
||||||
|
let _jwtSettingsBase: string | null = null;
|
||||||
|
let _jwtSettings: JwtSettings | null = null;
|
||||||
|
let _jwtSettingsFetchedAt = 0;
|
||||||
|
|
||||||
|
function authDebug(event: string, fields?: Record<string, unknown>) {
|
||||||
|
if (!AUTH_DEBUG) return;
|
||||||
|
if (fields) {
|
||||||
|
console.debug(`[auth] ${event}`, fields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(`[auth] ${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDuration(duration: string): number | null {
|
||||||
|
try {
|
||||||
|
const match = duration.match(
|
||||||
|
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, years, months, days, hours, minutes, seconds] = match;
|
||||||
|
let ms = 0;
|
||||||
|
if (years) ms += parseInt(years) * 365.25 * 24 * 60 * 60 * 1000;
|
||||||
|
if (months) ms += parseInt(months) * 30.44 * 24 * 60 * 60 * 1000;
|
||||||
|
if (days) ms += parseInt(days) * 24 * 60 * 60 * 1000;
|
||||||
|
if (hours) ms += parseInt(hours) * 60 * 60 * 1000;
|
||||||
|
if (minutes) ms += parseInt(minutes) * 60 * 1000;
|
||||||
|
if (seconds) ms += parseFloat(seconds) * 1000;
|
||||||
|
return ms;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpiryMs(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return null;
|
||||||
|
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
||||||
|
const decoded = atob(padded);
|
||||||
|
const json = JSON.parse(decoded) as { exp?: number };
|
||||||
|
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(expiresAt?: number | null, skewMs = TOKEN_REFRESH_SKEW_MS): boolean {
|
||||||
|
if (!expiresAt || !Number.isFinite(expiresAt)) return false;
|
||||||
|
return Date.now() >= expiresAt - skewMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withExpiryFromSettings(
|
||||||
|
accessToken: string,
|
||||||
|
jwt: JwtSettings | null,
|
||||||
|
): Pick<StoredUiAuthSession, "accessExpiresAt" | "refreshExpiresAt"> {
|
||||||
|
const now = Date.now();
|
||||||
|
const accessExpiresAt =
|
||||||
|
decodeJwtExpiryMs(accessToken)
|
||||||
|
?? (typeof jwt?.jwtTokenExpiry === "string" ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null);
|
||||||
|
const refreshExpiresAt =
|
||||||
|
typeof jwt?.jwtRefreshExpiry === "string" ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null;
|
||||||
|
return { accessExpiresAt, refreshExpiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJwtSettings(base: string): Promise<JwtSettings | null> {
|
||||||
|
const res = await fetchAuthenticated(
|
||||||
|
`${base}/api/graphql`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: gqlBody(
|
||||||
|
`query GetJWTSettings {
|
||||||
|
settings {
|
||||||
|
jwtAudience
|
||||||
|
jwtRefreshExpiry
|
||||||
|
jwtTokenExpiry
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
timeoutSignal(5000),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
authDebug("JWT settings fetch failed", { status: res.status });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (json?.errors?.length) {
|
||||||
|
authDebug("JWT settings query error", { errors: json.errors });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = json?.data?.settings;
|
||||||
|
if (!settings || typeof settings !== "object") {
|
||||||
|
authDebug("JWT settings missing or invalid", { settings });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
authDebug("JWT settings fetched", {
|
||||||
|
hasAudience: !!settings.jwtAudience,
|
||||||
|
tokenExpiry: settings.jwtTokenExpiry,
|
||||||
|
refreshExpiry: settings.jwtRefreshExpiry,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
jwtAudience: typeof settings.jwtAudience === "string" ? settings.jwtAudience : null,
|
||||||
|
jwtRefreshExpiry: typeof settings.jwtRefreshExpiry === "string" ? settings.jwtRefreshExpiry : null,
|
||||||
|
jwtTokenExpiry: typeof settings.jwtTokenExpiry === "string" ? settings.jwtTokenExpiry : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
||||||
|
const base = getServerBase();
|
||||||
|
const freshEnough = Date.now() - _jwtSettingsFetchedAt < 60_000;
|
||||||
|
if (!force && _jwtSettingsBase === base && _jwtSettings && freshEnough) return _jwtSettings;
|
||||||
|
|
||||||
|
const jwt = await fetchJwtSettings(base);
|
||||||
|
_jwtSettingsBase = base;
|
||||||
|
_jwtSettings = jwt;
|
||||||
|
_jwtSettingsFetchedAt = Date.now();
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
export const uiAuth = {
|
export const uiAuth = {
|
||||||
getToken: () => _accessToken,
|
getSession: () => {
|
||||||
setToken: (t: string) => { _accessToken = t; sessionStorage.setItem(TOKEN_KEY, t); },
|
const base = getServerBase();
|
||||||
clearToken: () => { _accessToken = null; sessionStorage.removeItem(TOKEN_KEY); },
|
if (_uiSession && _uiSession.base === base) return _uiSession;
|
||||||
|
|
||||||
|
const stored = readStoredSession();
|
||||||
|
if (!stored) return null;
|
||||||
|
if (stored.base !== base) {
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
_uiSession = null;
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiSession = stored;
|
||||||
|
_accessToken = stored.accessToken;
|
||||||
|
_accessTokenBase = stored.base;
|
||||||
|
return _uiSession;
|
||||||
|
},
|
||||||
|
setSession: (session: Omit<StoredUiAuthSession, "base">) => {
|
||||||
|
const base = getServerBase();
|
||||||
|
_uiSession = { ...session, base };
|
||||||
|
_accessToken = session.accessToken;
|
||||||
|
_accessTokenBase = base;
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_uiSession));
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
getToken: () => {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
if (isExpired(session.accessExpiresAt, 0)) return null;
|
||||||
|
|
||||||
|
const base = getServerBase();
|
||||||
|
if (_accessToken && _accessTokenBase === base) return _accessToken;
|
||||||
|
const stored = readStoredToken();
|
||||||
|
if (!stored) return null;
|
||||||
|
if (stored.base !== base) {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
_uiSession = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_accessToken = stored.token;
|
||||||
|
_accessTokenBase = stored.base;
|
||||||
|
return _accessToken;
|
||||||
|
},
|
||||||
|
setToken: (t: string) => {
|
||||||
|
const existing = uiAuth.getSession();
|
||||||
|
if (existing?.refreshToken) {
|
||||||
|
uiAuth.setSession({
|
||||||
|
...existing,
|
||||||
|
accessToken: t,
|
||||||
|
...withExpiryFromSettings(t, _jwtSettings),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = getServerBase();
|
||||||
|
_accessToken = t;
|
||||||
|
_accessTokenBase = base;
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base, token: t }));
|
||||||
|
},
|
||||||
|
setLoginSession: (payload: { accessToken: string; refreshToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||||
|
uiAuth.setSession({
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
refreshToken: payload.refreshToken,
|
||||||
|
clientMutationId: payload.clientMutationId,
|
||||||
|
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateAccessToken: (payload: { accessToken: string; clientMutationId?: string }, jwt: JwtSettings | null) => {
|
||||||
|
const existing = uiAuth.getSession();
|
||||||
|
if (!existing?.refreshToken) {
|
||||||
|
uiAuth.setToken(payload.accessToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uiAuth.setSession({
|
||||||
|
...existing,
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
clientMutationId: payload.clientMutationId ?? existing.clientMutationId,
|
||||||
|
...withExpiryFromSettings(payload.accessToken, jwt),
|
||||||
|
refreshToken: existing.refreshToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearToken: () => {
|
||||||
|
_accessToken = null;
|
||||||
|
_accessTokenBase = null;
|
||||||
|
_uiSession = null;
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authSession = {
|
export const authSession = {
|
||||||
clearTokens() { uiAuth.clearToken(); },
|
clearTokens() {
|
||||||
|
_refreshPromise = null;
|
||||||
|
_jwtSettings = null;
|
||||||
|
_jwtSettingsBase = null;
|
||||||
|
_jwtSettingsFetchedAt = 0;
|
||||||
|
uiAuth.clearToken();
|
||||||
|
},
|
||||||
hasSession(): boolean {
|
hasSession(): boolean {
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
if (mode === "UI_LOGIN") return _accessToken !== null;
|
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -32,6 +295,61 @@ function getServerBase(): string {
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredToken(): StoredAccessToken | null {
|
||||||
|
const session = readStoredSession();
|
||||||
|
if (session) return { base: session.base, token: session.accessToken };
|
||||||
|
|
||||||
|
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string")
|
||||||
|
return { base: parsed.base, token: parsed.token };
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated = { base: getServerBase(), token: raw.trim() };
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSession(): StoredUiAuthSession | null {
|
||||||
|
const raw = sessionStorage.getItem(UI_SESSION_KEY);
|
||||||
|
if (raw?.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.accessToken === "string") {
|
||||||
|
return {
|
||||||
|
base: parsed.base,
|
||||||
|
accessToken: parsed.accessToken,
|
||||||
|
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
|
||||||
|
clientMutationId: typeof parsed.clientMutationId === "string" ? parsed.clientMutationId : undefined,
|
||||||
|
accessExpiresAt: typeof parsed.accessExpiresAt === "number" ? parsed.accessExpiresAt : null,
|
||||||
|
refreshExpiresAt: typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!legacy?.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(legacy);
|
||||||
|
if (typeof parsed?.base === "string" && typeof parsed?.token === "string") {
|
||||||
|
const migrated: StoredUiAuthSession = { base: parsed.base, accessToken: parsed.token };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const migrated: StoredUiAuthSession = { base: getServerBase(), accessToken: legacy.trim() };
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(migrated));
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
function timeoutSignal(ms: number): AbortSignal {
|
function timeoutSignal(ms: number): AbortSignal {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTimeout(() => controller.abort(), ms);
|
setTimeout(() => controller.abort(), ms);
|
||||||
@@ -69,27 +387,172 @@ export async function fetchAuthenticated(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "UI_LOGIN") {
|
if (mode === "UI_LOGIN") {
|
||||||
const token = uiAuth.getToken();
|
const token = await getUIAccessToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||||
throw new AuthRequiredError();
|
throw new AuthRequiredError();
|
||||||
}
|
}
|
||||||
return fetch(url, {
|
|
||||||
|
let res = await fetch(url, {
|
||||||
...init, signal, credentials: "omit",
|
...init, signal, credentials: "omit",
|
||||||
headers: { ...baseHeaders, ...bearerHeader(token) },
|
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status !== 401 || skipped) return res;
|
||||||
|
|
||||||
|
const refreshed = await refreshUiAccessToken(true);
|
||||||
|
if (!refreshed) return res;
|
||||||
|
|
||||||
|
res = await fetch(url, {
|
||||||
|
...init, signal, credentials: "omit",
|
||||||
|
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
||||||
|
});
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
if (forceRefresh || isExpired(session.accessExpiresAt)) {
|
||||||
|
return refreshUiAccessToken(true);
|
||||||
|
}
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
if (!session.refreshToken) {
|
||||||
|
if (force && isExpired(session.accessExpiresAt, 0)) return null;
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && !isExpired(session.accessExpiresAt)) return session.accessToken;
|
||||||
|
if (isExpired(session.refreshExpiresAt)) {
|
||||||
|
authDebug("refresh skipped: refresh token expired", {
|
||||||
|
force,
|
||||||
|
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_refreshPromise) {
|
||||||
|
authDebug("refresh joined existing request");
|
||||||
|
return _refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
authDebug("refresh start", {
|
||||||
|
force,
|
||||||
|
accessExpiresAt: session.accessExpiresAt ?? null,
|
||||||
|
refreshExpiresAt: session.refreshExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
_refreshPromise = (async () => {
|
||||||
|
const base = getServerBase();
|
||||||
|
const jwt = await getJwtSettings().catch(() => null);
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "omit",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: gqlBody(
|
||||||
|
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ refreshToken: session.refreshToken, clientMutationId: session.clientMutationId ?? undefined },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
authDebug("refresh rejected by server", { status: res.status });
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
authDebug("refresh failed with HTTP error", { status: res.status });
|
||||||
|
throw new Error(`Token refresh failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const refreshed = json?.data?.refreshToken;
|
||||||
|
const nextAccessToken: string | undefined = refreshed?.accessToken;
|
||||||
|
if (!nextAccessToken) {
|
||||||
|
const msg = json?.errors?.[0]?.message;
|
||||||
|
if (msg && /unauthorized|unauthenticated|forbidden/i.test(msg)) {
|
||||||
|
authDebug("refresh rejected by GraphQL error", { message: msg });
|
||||||
|
uiAuth.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
authDebug("refresh returned no access token", { message: msg ?? null });
|
||||||
|
throw new Error(msg ?? "Token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
uiAuth.updateAccessToken(
|
||||||
|
{
|
||||||
|
accessToken: nextAccessToken,
|
||||||
|
clientMutationId: typeof refreshed?.clientMutationId === "string"
|
||||||
|
? refreshed.clientMutationId
|
||||||
|
: session.clientMutationId,
|
||||||
|
},
|
||||||
|
jwt,
|
||||||
|
);
|
||||||
|
authDebug("refresh success", {
|
||||||
|
nextAccessExpiresAt: uiAuth.getSession()?.accessExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
return nextAccessToken;
|
||||||
|
})()
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
authDebug("refresh threw error", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_refreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return _refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUiAuthDebugStatus(now = Date.now()): UiAuthDebugStatus {
|
||||||
|
const session = uiAuth.getSession();
|
||||||
|
const accessExpiresAt = session?.accessExpiresAt ?? null;
|
||||||
|
const refreshExpiresAt = session?.refreshExpiresAt ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: (store.settings.serverAuthMode ?? "NONE") as AuthMode,
|
||||||
|
serverBase: getServerBase(),
|
||||||
|
hasSession: !!session,
|
||||||
|
hasRefreshToken: !!session?.refreshToken,
|
||||||
|
accessExpiresAt,
|
||||||
|
refreshExpiresAt,
|
||||||
|
accessExpiresInMs: accessExpiresAt ? accessExpiresAt - now : null,
|
||||||
|
refreshExpiresInMs: refreshExpiresAt ? refreshExpiresAt - now : null,
|
||||||
|
shouldRefreshSoon: isExpired(accessExpiresAt),
|
||||||
|
refreshInFlight: _refreshPromise !== null,
|
||||||
|
skewMs: TOKEN_REFRESH_SKEW_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||||
method: "POST", credentials: "omit",
|
method: "POST", credentials: "omit",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: gqlBody(
|
body: gqlBody(
|
||||||
`mutation Login($username: String!, $password: String!) {
|
`mutation Login($username: String!, $password: String!) {
|
||||||
login(input: { username: $username, password: $password }) { accessToken }
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
}`,
|
}`,
|
||||||
{ username: user, password: pass },
|
{ username: user, password: pass },
|
||||||
),
|
),
|
||||||
@@ -97,10 +560,24 @@ export async function loginUI(user: string, pass: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const token: string | undefined = json?.data?.login?.accessToken;
|
const payload = json?.data?.login;
|
||||||
if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
const accessToken: string | undefined = payload?.accessToken;
|
||||||
uiAuth.setToken(token);
|
const refreshToken: string | undefined = payload?.refreshToken;
|
||||||
updateSettings({ serverAuthMode: "UI_LOGIN" });
|
if (!accessToken || !refreshToken) throw new Error(json?.errors?.[0]?.message ?? "Login failed");
|
||||||
|
|
||||||
|
authDebug("login success", { user });
|
||||||
|
|
||||||
|
const preliminarySession = {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
clientMutationId: typeof payload?.clientMutationId === "string" ? payload.clientMutationId : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
uiAuth.setLoginSession(preliminarySession, null);
|
||||||
|
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: user, serverAuthPass: "" });
|
||||||
|
|
||||||
|
const jwt = await getJwtSettings(true).catch(() => null);
|
||||||
|
uiAuth.setLoginSession(preliminarySession, jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
@@ -123,8 +600,9 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
|
|||||||
const base = getServerBase();
|
const base = getServerBase();
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
const s = store.settings;
|
const s = store.settings;
|
||||||
|
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
||||||
|
|
||||||
if (mode === "UI_LOGIN" && !_accessToken) return "auth_required";
|
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
@@ -132,8 +610,8 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unreachab
|
|||||||
const user = s.serverAuthUser?.trim() ?? "";
|
const user = s.serverAuthUser?.trim() ?? "";
|
||||||
const pass = s.serverAuthPass?.trim() ?? "";
|
const pass = s.serverAuthPass?.trim() ?? "";
|
||||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||||
} else if (mode === "UI_LOGIN" && _accessToken) {
|
} else if (mode === "UI_LOGIN" && token) {
|
||||||
Object.assign(headers, bearerHeader(_accessToken));
|
Object.assign(headers, bearerHeader(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
|||||||
Vendored
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { uiAuth } from "@core/auth";
|
import { getUIAccessToken } from "@core/auth";
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
@@ -18,10 +18,10 @@ interface QueueEntry {
|
|||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||||
if (mode === "UI_LOGIN") {
|
if (mode === "UI_LOGIN") {
|
||||||
const token = uiAuth.getToken();
|
const token = await getUIAccessToken();
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
if (mode === "BASIC_AUTH") {
|
if (mode === "BASIC_AUTH") {
|
||||||
@@ -33,7 +33,8 @@ function getAuthHeaders(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doFetch(url: string): Promise<string> {
|
async function doFetch(url: string): Promise<string> {
|
||||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
const headers = await getAuthHeaders();
|
||||||
|
const res = await tauriFetch(url, { method: "GET", headers });
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
|
|||||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AVG_BYTES_PER_PAGE = 1_500_000;
|
||||||
|
|
||||||
|
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const item of queue) {
|
||||||
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
|
const remaining = pages - Math.round(item.progress * pages);
|
||||||
|
total += remaining * AVG_BYTES_PER_PAGE;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte";
|
|||||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||||
import {
|
import {
|
||||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||||
isRunning, getErrored, calcSpeed, estimateEta,
|
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||||
type SpeedSample,
|
type SpeedSample,
|
||||||
} from "../lib/downloadQueue";
|
} from "../lib/downloadQueue";
|
||||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
class DownloadStore {
|
class DownloadStore {
|
||||||
status: DownloadStatus | null = $state(null);
|
status: DownloadStatus | null = $state(null);
|
||||||
@@ -23,8 +24,11 @@ class DownloadStore {
|
|||||||
dequeueing = $state(new Set<number>());
|
dequeueing = $state(new Set<number>());
|
||||||
selected = $state(new Set<number>());
|
selected = $state(new Set<number>());
|
||||||
batchWorking = $state(false);
|
batchWorking = $state(false);
|
||||||
pagesPerSec: number | null = $state(null);
|
pagesPerSec: number | null = $state(null);
|
||||||
eta: number | null = $state(null);
|
eta: number | null = $state(null);
|
||||||
|
storageWarning: boolean = $state(false);
|
||||||
|
|
||||||
|
private freeBytes: number | null = null;
|
||||||
|
|
||||||
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||||
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||||
@@ -82,6 +86,52 @@ class DownloadStore {
|
|||||||
this.status = ds;
|
this.status = ds;
|
||||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||||
this.updateSpeed(ds);
|
this.updateSpeed(ds);
|
||||||
|
this.fetchFreeBytes(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFreeBytes(ds: DownloadStatus) {
|
||||||
|
const path = store.settings.serverDownloadsPath ?? "";
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path });
|
||||||
|
this.freeBytes = info.free_bytes;
|
||||||
|
this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmStorageOverrun(): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both";
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
|
||||||
|
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||||
|
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
|
||||||
|
The download queue is estimated to exceed 95% of your available storage. Download anyway?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
|
||||||
|
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
|
||||||
|
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(panel);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
function finish(result: boolean) { backdrop.remove(); resolve(result); }
|
||||||
|
panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false));
|
||||||
|
panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true));
|
||||||
|
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
|
||||||
|
if (this.freeBytes === null) return true;
|
||||||
|
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
|
||||||
|
return this.confirmStorageOverrun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSpeed(ds: DownloadStatus) {
|
private updateSpeed(ds: DownloadStatus) {
|
||||||
@@ -172,11 +222,21 @@ class DownloadStore {
|
|||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enqueue(chapterId: number): Promise<boolean> {
|
||||||
|
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any];
|
||||||
|
if (!(await this.guardStorage(projected))) return false;
|
||||||
|
try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async retryOne(chapterId: number) {
|
async retryOne(chapterId: number) {
|
||||||
if (this.dequeueing.has(chapterId)) return;
|
if (this.dequeueing.has(chapterId)) return;
|
||||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||||
|
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
@@ -189,6 +249,8 @@ class DownloadStore {
|
|||||||
const ids = [...this.erroredIds];
|
const ids = [...this.erroredIds];
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
|
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
this.poll();
|
this.poll();
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
@@ -204,6 +266,8 @@ class DownloadStore {
|
|||||||
try {
|
try {
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
||||||
|
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
|
||||||
|
if (!(await this.guardStorage(projected))) { this.poll(); return; }
|
||||||
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
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 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
|
|
||||||
let activeDragKind: "tab" | null = $state(null);
|
let activeDragKind: "tab" | null = $state(null);
|
||||||
let dragInsertIdx: number = $state(-1);
|
let dragInsertIdx: number = $state(-1);
|
||||||
let dragTabId: number | null = $state(null);
|
let dragTabId: string | null = $state(null);
|
||||||
let dragOverTabId: number | null = $state(null);
|
let dragOverTabId: string | null = $state(null);
|
||||||
|
|
||||||
const DT_TAB = "application/x-moku-tab";
|
const DT_TAB = "application/x-moku-tab";
|
||||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||||
@@ -95,7 +95,8 @@
|
|||||||
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||||
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
||||||
const ordered = [...pinned.filter(id => known.has(id))];
|
const eligible = pinned.filter(id => known.has(id));
|
||||||
|
const ordered = [...eligible];
|
||||||
const inOrder = new Set(ordered);
|
const inOrder = new Set(ordered);
|
||||||
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
||||||
if (!inOrder.has(id)) ordered.push(id);
|
if (!inOrder.has(id)) ordered.push(id);
|
||||||
@@ -517,45 +518,54 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragStart(e: DragEvent, cat: Category) {
|
function onTabDragStart(e: DragEvent, id: string) {
|
||||||
activeDragKind = "tab"; dragTabId = cat.id;
|
activeDragKind = "tab"; dragTabId = id;
|
||||||
e.dataTransfer!.effectAllowed = "move";
|
e.dataTransfer!.effectAllowed = "move";
|
||||||
e.dataTransfer!.setData(DT_TAB, String(cat.id));
|
e.dataTransfer!.setData(DT_TAB, id);
|
||||||
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
|
e.dataTransfer!.setData("text/plain", `tab:${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
|
||||||
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
|
||||||
dragOverTabId = cat.id;
|
dragOverTabId = id;
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragLeave() { dragOverTabId = null; }
|
function onTabDragLeave() { dragOverTabId = null; }
|
||||||
|
|
||||||
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
async function onTabDrop(e: DragEvent, dropId: string) {
|
||||||
e.preventDefault(); dragOverTabId = null;
|
e.preventDefault(); dragOverTabId = null;
|
||||||
const insertAt = dragInsertIdx;
|
const insertAt = dragInsertIdx;
|
||||||
dragInsertIdx = -1;
|
dragInsertIdx = -1;
|
||||||
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
|
||||||
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
|
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
|
||||||
const dragStrId = String(dragId);
|
|
||||||
const tabs = [...visibleTabIds];
|
const tabs = [...allTabIds];
|
||||||
const fromIdx = tabs.indexOf(dragStrId);
|
const fromIdx = tabs.indexOf(dragStrId);
|
||||||
if (fromIdx < 0) return;
|
const dropIdx = tabs.indexOf(dropId);
|
||||||
|
if (fromIdx < 0 || dropIdx < 0) return;
|
||||||
|
|
||||||
|
const visibleDrop = visibleTabIds[insertAt] ?? null;
|
||||||
|
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
|
||||||
|
|
||||||
tabs.splice(fromIdx, 1);
|
tabs.splice(fromIdx, 1);
|
||||||
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length));
|
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
|
||||||
tabs.splice(dest, 0, dragStrId);
|
tabs.splice(adjustedDest, 0, dragStrId);
|
||||||
|
|
||||||
updateSettings({ libraryPinnedTabOrder: tabs });
|
updateSettings({ libraryPinnedTabOrder: tabs });
|
||||||
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
|
||||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||||
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
|
||||||
setCategories([...zeroCat, ...reordered]);
|
setCategories([...zeroCat, ...reordered]);
|
||||||
const serverPos = catIds.indexOf(dragStrId) + 1;
|
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
|
||||||
try {
|
if (!dragIsBuiltin) {
|
||||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos });
|
const serverPos = catIds.indexOf(dragStrId) + 1;
|
||||||
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
try {
|
||||||
|
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
|
||||||
|
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
search: string;
|
search: string;
|
||||||
activeDragKind: "tab" | null;
|
activeDragKind: "tab" | null;
|
||||||
dragInsertIdx: number;
|
dragInsertIdx: number;
|
||||||
dragTabId: number | null;
|
dragTabId: string | null;
|
||||||
dragOverTabId: number | null;
|
dragOverTabId: string | null;
|
||||||
sortPanelOpen: boolean;
|
sortPanelOpen: boolean;
|
||||||
filterPanelOpen: boolean;
|
filterPanelOpen: boolean;
|
||||||
tabsEl: HTMLDivElement;
|
tabsEl: HTMLDivElement;
|
||||||
@@ -39,10 +39,10 @@
|
|||||||
onSortPanelToggle: () => void;
|
onSortPanelToggle: () => void;
|
||||||
onFilterPanelToggle: () => void;
|
onFilterPanelToggle: () => void;
|
||||||
onOpenDownloadsFolder: () => void;
|
onOpenDownloadsFolder: () => void;
|
||||||
onTabDragStart: (e: DragEvent, cat: Category) => void;
|
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||||
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
|
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||||
onTabDragLeave: () => void;
|
onTabDragLeave: () => void;
|
||||||
onTabDrop: (e: DragEvent, cat: Category) => void;
|
onTabDrop: (e: DragEvent, id: string) => void;
|
||||||
onTabDragEnd: () => void;
|
onTabDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,20 +100,23 @@
|
|||||||
{#each visibleTabIds as id, idx}
|
{#each visibleTabIds as id, idx}
|
||||||
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||||
{#if id === "library" || id === "downloaded" || cat}
|
{#if id === "library" || id === "downloaded" || cat}
|
||||||
|
{@const isBuiltin = id === "library" || id === "downloaded"}
|
||||||
|
{@const isCompleted = cat && id === String(completedCatId)}
|
||||||
|
{@const isDraggable = true}
|
||||||
{#if activeDragKind === "tab" && dragInsertIdx === idx}
|
{#if activeDragKind === "tab" && dragInsertIdx === idx}
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
class:active={tab === id}
|
class:active={tab === id}
|
||||||
class:tab-dragging={cat && dragTabId === cat.id}
|
class:tab-dragging={isDraggable && dragTabId === id}
|
||||||
draggable={!!cat && id !== String(completedCatId)}
|
draggable={isDraggable}
|
||||||
onclick={() => onTabChange(id)}
|
onclick={() => onTabChange(id)}
|
||||||
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
|
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
|
||||||
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
|
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
|
||||||
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
|
ondragleave={isDraggable ? onTabDragLeave : undefined}
|
||||||
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
|
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
|
||||||
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
|
ondragend={isDraggable ? onTabDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{#if id === "library"}<Books size={11} weight="bold" />
|
{#if id === "library"}<Books size={11} weight="bold" />
|
||||||
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
|
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
|
||||||
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||||
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
|
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
|
||||||
import {
|
import {
|
||||||
store, addToast, openReader, setActiveManga,
|
store, addToast, openReader, setActiveManga,
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
enqueueing = new Set(enqueueing).add(ch.id);
|
enqueueing = new Set(enqueueing).add(ch.id);
|
||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
await downloadStore.enqueue(ch.id);
|
||||||
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
@@ -329,7 +329,10 @@
|
|||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
if (!chapterIds.length) return;
|
if (!chapterIds.length) return;
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
for (const id of chapterIds) {
|
||||||
|
const allowed = await downloadStore.enqueue(id);
|
||||||
|
if (!allowed) return;
|
||||||
|
}
|
||||||
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
}
|
}
|
||||||
@@ -461,7 +464,7 @@
|
|||||||
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
||||||
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
|
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : downloadStore.enqueue(ch.id) },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
|
||||||
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
|
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
|
||||||
import { store, addToast } from "@store/state.svelte";
|
import { store, addToast } from "@store/state.svelte";
|
||||||
import { cache } from "@core/cache/index";
|
import { cache } from "@core/cache/index";
|
||||||
|
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from "@core/auth";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
|
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
|
||||||
@@ -12,13 +13,69 @@
|
|||||||
let appVersion = $state("…");
|
let appVersion = $state("…");
|
||||||
let helloAvailable = $state<boolean | null>(null);
|
let helloAvailable = $state<boolean | null>(null);
|
||||||
let helloBusy = $state(false);
|
let helloBusy = $state(false);
|
||||||
|
let authStatus = $state<UiAuthDebugStatus | null>(null);
|
||||||
|
let authRefreshBusy = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
|
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
|
||||||
refreshPerfMetrics();
|
refreshPerfMetrics();
|
||||||
|
refreshAuthStatus();
|
||||||
invoke<boolean>("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false);
|
invoke<boolean>("windows_hello_available").then(v => helloAvailable = v).catch(() => helloAvailable = false);
|
||||||
|
|
||||||
|
const timer = setInterval(() => refreshAuthStatus(), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function refreshAuthStatus() {
|
||||||
|
authStatus = getUiAuthDebugStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCountdown(ms: number | null): string {
|
||||||
|
if (ms === null) return "—";
|
||||||
|
if (ms <= 0) return "expired";
|
||||||
|
|
||||||
|
const total = Math.floor(ms / 1000);
|
||||||
|
const month = 30 * 24 * 60 * 60;
|
||||||
|
const day = 24 * 60 * 60;
|
||||||
|
const hour = 60 * 60;
|
||||||
|
const minute = 60;
|
||||||
|
|
||||||
|
const months = Math.floor(total / month);
|
||||||
|
const days = Math.floor((total % month) / day);
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const remainingHours = Math.floor((total % day) / hour);
|
||||||
|
const mins = Math.floor((total % hour) / minute);
|
||||||
|
const secs = total % 60;
|
||||||
|
|
||||||
|
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`;
|
||||||
|
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
|
||||||
|
if (mins > 0) return `${mins}m ${secs}s`;
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number | null): string {
|
||||||
|
if (ts === null) return "—";
|
||||||
|
return new Date(ts).toLocaleString([], { dateStyle: "medium", timeStyle: "medium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceTokenRefresh() {
|
||||||
|
authRefreshBusy = true;
|
||||||
|
try {
|
||||||
|
const token = await refreshUiAccessToken(true);
|
||||||
|
addToast({
|
||||||
|
kind: token ? "success" : "info",
|
||||||
|
title: "UI auth refresh",
|
||||||
|
body: token ? "Refresh succeeded" : "No refreshed token available",
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "UI auth refresh", body: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
authRefreshBusy = false;
|
||||||
|
refreshAuthStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPerfMetrics() {
|
function refreshPerfMetrics() {
|
||||||
let entries = 0, oldest: number | null = null, newest: number | null = null;
|
let entries = 0, oldest: number | null = null, newest: number | null = null;
|
||||||
const foundKeys: string[] = [];
|
const foundKeys: string[] = [];
|
||||||
@@ -75,7 +132,7 @@
|
|||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
|
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
|
||||||
<div class="s-dev-pill-group">
|
<div class="s-dev-pill-group">
|
||||||
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]}
|
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label] (kind)}
|
||||||
<button class="s-dev-pill {kind}" onclick={() => addToast({
|
<button class="s-dev-pill {kind}" onclick={() => addToast({
|
||||||
kind,
|
kind,
|
||||||
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
|
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
|
||||||
@@ -122,7 +179,7 @@
|
|||||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
|
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
|
||||||
<span class="s-desc">3D tilt cards — hover to preview</span>
|
<span class="s-desc">3D tilt cards — hover to preview</span>
|
||||||
<div style="display:flex;gap:var(--sp-3)">
|
<div style="display:flex;gap:var(--sp-3)">
|
||||||
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card}
|
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card (card.title)}
|
||||||
<ThreeDCard>
|
<ThreeDCard>
|
||||||
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
|
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
|
||||||
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
|
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
|
||||||
@@ -159,4 +216,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Auth (UI Login)</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
<div class="s-dev-grid">
|
||||||
|
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? "—"}</span>
|
||||||
|
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? "present" : "none"}</span>
|
||||||
|
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? "present" : "none"}</span>
|
||||||
|
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
|
||||||
|
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
|
||||||
|
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? "open" : "not yet"}</span>
|
||||||
|
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? "yes" : "no"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
|
||||||
|
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
|
||||||
|
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-btn-row">
|
||||||
|
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
|
||||||
|
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh} disabled={authRefreshBusy || authStatus?.mode !== "UI_LOGIN" || !authStatus?.hasRefreshToken}>
|
||||||
|
{authRefreshBusy ? "Refreshing…" : "Force refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -6,13 +6,15 @@
|
|||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
import { store, updateSettings, setCategories } from "@store/state.svelte";
|
import { store, updateSettings, setCategories } from "@store/state.svelte";
|
||||||
|
|
||||||
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
||||||
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
||||||
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
||||||
const orderedCatIds = $derived.by(() => {
|
|
||||||
const order = store.settings.libraryPinnedTabOrder ?? [];
|
const orderedAllIds = $derived.by(() => {
|
||||||
const known = new Set(sortedCatIds);
|
const order = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
|
const allIds = ["library", "downloaded", ...sortedCatIds];
|
||||||
|
const known = new Set(allIds);
|
||||||
|
return [...new Set([...order.filter(id => known.has(id)), ...allIds])];
|
||||||
});
|
});
|
||||||
|
|
||||||
let catsLoading = $state(false);
|
let catsLoading = $state(false);
|
||||||
@@ -21,9 +23,9 @@
|
|||||||
let editingId = $state<number | null>(null);
|
let editingId = $state<number | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
|
|
||||||
let dragId = $state<number | null>(null);
|
let dragStrId = $state<string | null>(null);
|
||||||
let dragOverId = $state<number | null>(null);
|
let dragOverStrId = $state<string | null>(null);
|
||||||
let dropPosition = $state<"above" | "below" | null>(null);
|
let dropPosition = $state<"above" | "below" | null>(null);
|
||||||
|
|
||||||
function isHidden(id: string) {
|
function isHidden(id: string) {
|
||||||
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
|
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
|
||||||
@@ -92,57 +94,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyReorder(fromId: number, toId: number) {
|
function applyReorder(fromStrId: string, toStrId: string) {
|
||||||
const zeroCat = store.categories.filter(c => c.id === 0);
|
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||||
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
const allIds = ["library", "downloaded", ...catIds];
|
||||||
const fromIdx = sortable.findIndex(c => c.id === fromId);
|
const current = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
const toIdx = sortable.findIndex(c => c.id === toId);
|
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])];
|
||||||
|
const fromIdx = base.indexOf(fromStrId);
|
||||||
|
const toIdx = base.indexOf(toStrId);
|
||||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
||||||
const reordered = [...sortable];
|
base.splice(fromIdx, 1);
|
||||||
const [moved] = reordered.splice(fromIdx, 1);
|
base.splice(toIdx, 0, fromStrId);
|
||||||
reordered.splice(toIdx, 0, moved);
|
updateSettings({ libraryPinnedTabOrder: base });
|
||||||
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
|
||||||
|
|
||||||
const catIds = reordered.map(c => String(c.id));
|
const fromNumId = Number(fromStrId);
|
||||||
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
|
if (!isNaN(fromNumId) && fromStrId !== "library" && fromStrId !== "downloaded") {
|
||||||
|
const zeroCat = store.categories.filter(c => c.id === 0);
|
||||||
try {
|
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
|
||||||
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
|
const sFromIdx = sortable.findIndex(c => c.id === fromNumId);
|
||||||
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId);
|
||||||
setCategories([
|
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
|
||||||
...zeroCat,
|
const reordered = [...sortable];
|
||||||
...updated.sort((a, b) => a.order - b.order).map(fresh => {
|
const [moved] = reordered.splice(sFromIdx, 1);
|
||||||
const existing = store.categories.find(c => c.id === fresh.id);
|
reordered.splice(sToIdx, 0, moved);
|
||||||
return existing ? { ...existing, ...fresh } : fresh;
|
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||||
}),
|
gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromNumId, position: sToIdx + 1 })
|
||||||
]);
|
.then(res => {
|
||||||
} catch (e: any) {
|
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||||
catsError = e?.message ?? "Failed to reorder";
|
setCategories([
|
||||||
await loadCategories();
|
...zeroCat,
|
||||||
|
...updated.sort((a, b) => a.order - b.order).map(fresh => {
|
||||||
|
const existing = store.categories.find(c => c.id === fresh.id);
|
||||||
|
return existing ? { ...existing, ...fresh } : fresh;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.catch(async (e: any) => {
|
||||||
|
catsError = e?.message ?? "Failed to reorder";
|
||||||
|
await loadCategories();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragStart(e: DragEvent, id: number) {
|
function onDragStart(e: DragEvent, id: string) {
|
||||||
dragId = id;
|
dragStrId = id;
|
||||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); }
|
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(e: DragEvent, id: number) {
|
function onDragOver(e: DragEvent, id: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
if (dragId === id) return;
|
if (dragStrId === id) return;
|
||||||
dragOverId = id;
|
dragOverStrId = id;
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
|
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent, id: number) {
|
function onDrop(e: DragEvent, id: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragId !== null && dragId !== id) applyReorder(dragId, id);
|
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id);
|
||||||
dragId = null; dragOverId = null; dropPosition = null;
|
dragStrId = null; dragOverStrId = null; dropPosition = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; }
|
function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null; }
|
||||||
|
|
||||||
function focusInput(node: HTMLElement) { node.focus(); }
|
function focusInput(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
@@ -166,96 +180,94 @@
|
|||||||
{#if catsLoading}
|
{#if catsLoading}
|
||||||
<p class="s-empty">Loading folders…</p>
|
<p class="s-empty">Loading folders…</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="s-folder-row s-folder-row-static">
|
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
|
||||||
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
|
{#each orderedAllIds as id}
|
||||||
<span class="s-folder-name s-folder-name-static">Saved</span>
|
{@const isBuiltin = id === "library" || id === "downloaded"}
|
||||||
<span class="s-folder-badge">built-in</span>
|
{@const isCompleted = id === completedId}
|
||||||
<div class="s-folder-actions">
|
{@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)}
|
||||||
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
|
{@const hidden = isHidden(id)}
|
||||||
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="s-folder-row s-folder-row-static">
|
|
||||||
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
|
|
||||||
<span class="s-folder-name s-folder-name-static">Downloaded</span>
|
|
||||||
<span class="s-folder-badge">built-in</span>
|
|
||||||
<div class="s-folder-actions">
|
|
||||||
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
|
|
||||||
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if completedCat}
|
{#if isBuiltin || cat}
|
||||||
<div class="s-folder-row s-folder-row-static">
|
|
||||||
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
|
|
||||||
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
|
|
||||||
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
|
|
||||||
<span class="s-folder-badge">built-in</span>
|
|
||||||
<div class="s-folder-actions">
|
|
||||||
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
|
|
||||||
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="s-folder-divider" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
|
||||||
{#each orderedCatIds.filter(id => id !== completedId) as id}
|
|
||||||
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
|
|
||||||
{@const hidden = isHidden(id)}
|
|
||||||
{#if cat}
|
|
||||||
<div
|
<div
|
||||||
class="s-folder-row"
|
class="s-folder-row"
|
||||||
class:dragging={dragId === cat.id}
|
class:dragging={dragStrId === id}
|
||||||
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === "above"}
|
||||||
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === "below"}
|
||||||
ondragover={(e) => onDragOver(e, cat.id)}
|
draggable="true"
|
||||||
ondrop={(e) => onDrop(e, cat.id)}
|
ondragstart={(e) => onDragStart(e, id)}
|
||||||
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
ondragover={(e) => onDragOver(e, id)}
|
||||||
|
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }}
|
||||||
|
ondrop={(e) => onDrop(e, id)}
|
||||||
|
ondragend={onDragEnd}
|
||||||
>
|
>
|
||||||
{#if editingId === cat.id}
|
{#if isCompleted}
|
||||||
<input class="s-input full" bind:value={editingName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
|
||||||
onblur={commitEdit} use:focusInput />
|
|
||||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
|
||||||
{:else}
|
|
||||||
<div class="s-folder-identity" draggable="true"
|
|
||||||
ondragstart={(e) => onDragStart(e, cat.id)}
|
|
||||||
ondragend={onDragEnd}>
|
|
||||||
<span class="s-folder-icon">
|
|
||||||
<FolderSimple size={14} weight="light" />
|
|
||||||
<DotsSixVertical size={14} weight="bold" />
|
|
||||||
</span>
|
|
||||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
|
||||||
|
|
||||||
|
<span class="s-folder-icon">
|
||||||
|
<CheckSquare size={14} weight="light" />
|
||||||
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<span class="s-folder-name">{cat?.name ?? "Completed"}</span>
|
||||||
|
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
<div class="s-folder-actions">
|
<div class="s-folder-actions">
|
||||||
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
|
||||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
|
||||||
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
|
||||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
|
||||||
</button>
|
|
||||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if isBuiltin}
|
||||||
|
<span class="s-folder-icon">
|
||||||
|
{#if id === "library"}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
|
||||||
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<span class="s-folder-name">{id === "library" ? "Saved" : "Downloaded"}</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if cat}
|
||||||
|
{#if editingId === cat.id}
|
||||||
|
<input class="s-input full" bind:value={editingName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||||
|
onblur={commitEdit} use:focusInput />
|
||||||
|
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||||
|
{:else}
|
||||||
|
<div class="s-folder-identity" draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, id)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<span class="s-folder-icon">
|
||||||
|
<FolderSimple size={14} weight="light" />
|
||||||
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
||||||
|
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
||||||
|
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||||
|
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||||
|
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||||
|
<Trash size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -314,28 +326,24 @@
|
|||||||
.s-folder-row.drop-above::before { top: -1px; }
|
.s-folder-row.drop-above::before { top: -1px; }
|
||||||
.s-folder-row.drop-below::after { bottom: -1px; }
|
.s-folder-row.drop-below::after { bottom: -1px; }
|
||||||
|
|
||||||
.s-folder-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.s-folder-row-static {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.s-folder-icon-static {
|
.s-folder-icon-static {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-faint);
|
color: var(--text-primary);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-folder-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
.s-folder-icon {
|
.s-folder-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -371,14 +379,6 @@
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.s-folder-name-static {
|
|
||||||
cursor: default;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.s-folder-name-static:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.s-folder-actions {
|
.s-folder-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -400,12 +400,6 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.s-folder-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.s-btn-icon.active {
|
.s-btn-icon.active {
|
||||||
color: var(--accent, #6c8ef5);
|
color: var(--accent, #6c8ef5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { store, updateSettings } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
import { selectPortal } from "@core/actions/selectPortal";
|
import { selectPortal } from "@core/actions/selectPortal";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null;
|
selectOpen: string | null;
|
||||||
@@ -12,6 +13,12 @@
|
|||||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props();
|
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props();
|
||||||
|
|
||||||
let triggerIdleTimeout = $state<HTMLButtonElement>(null!);
|
let triggerIdleTimeout = $state<HTMLButtonElement>(null!);
|
||||||
|
let serverAdvancedOpen = $state(false);
|
||||||
|
|
||||||
|
async function pickServerBinary() {
|
||||||
|
const picked = await invoke<string | null>("pick_server_binary");
|
||||||
|
if (picked) updateSettings({ serverBinary: picked });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
@@ -43,14 +50,70 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Server</p>
|
<p class="s-section-title">Server</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
|
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Server URL</span><span class="s-desc">Base URL of your Suwayomi instance</span></div>
|
<div class="s-row-info">
|
||||||
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
<span class="s-label">Server URL</span>
|
||||||
|
<span class="s-desc">Base URL of your Suwayomi instance</span>
|
||||||
|
</div>
|
||||||
|
<div class="srv-url-group">
|
||||||
|
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"}
|
||||||
|
oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })}
|
||||||
|
placeholder="http://localhost:4567" spellcheck="false" />
|
||||||
|
<button
|
||||||
|
class="srv-adv-btn"
|
||||||
|
class:open={serverAdvancedOpen}
|
||||||
|
onclick={() => serverAdvancedOpen = !serverAdvancedOpen}
|
||||||
|
title="Server launch options"
|
||||||
|
aria-expanded={serverAdvancedOpen}
|
||||||
|
>
|
||||||
|
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||||
|
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="s-row">
|
<label class="s-row">
|
||||||
<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>
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{#if serverAdvancedOpen}
|
||||||
|
<div class="srv-adv-panel">
|
||||||
|
<div class="srv-adv-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Server binary</span>
|
||||||
|
<span class="s-desc">Path to server executable — leave blank to use bundled</span>
|
||||||
|
</div>
|
||||||
|
<div class="srv-file-group">
|
||||||
|
<input class="s-input srv-path-input" value={store.settings.serverBinary ?? ""}
|
||||||
|
oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })}
|
||||||
|
placeholder="auto-detect" spellcheck="false" />
|
||||||
|
<button class="srv-file-btn" onclick={pickServerBinary} title="Browse">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M1.5 4.5h11v7a1 1 0 01-1 1h-9a1 1 0 01-1-1v-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||||
|
<path d="M1.5 4.5l1.8-2.5h3.4l1.3 2.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +146,7 @@
|
|||||||
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
|
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
|
||||||
<div class="s-seg">
|
<div class="s-seg">
|
||||||
{#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]}
|
{#each [["ask","Ask"],["tray","Tray"],["quit","Quit"]] as [v, l]}
|
||||||
<button class="s-seg-btn" class:active={( store.settings.closeAction ?? "ask") === v} onclick={() => updateSettings({ closeAction: v as "ask" | "tray" | "quit" })}>{l}</button>
|
<button class="s-seg-btn" class:active={(store.settings.closeAction ?? "ask") === v} onclick={() => updateSettings({ closeAction: v as "ask" | "tray" | "quit" })}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,4 +196,70 @@
|
|||||||
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
|
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
|
||||||
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
|
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
|
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.srv-url-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srv-adv-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.srv-adv-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.srv-adv-btn.open { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||||
|
.srv-adv-btn svg { transition: transform var(--t-base); }
|
||||||
|
.srv-adv-btn.open svg { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.srv-adv-panel {
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srv-adv-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px var(--sp-4);
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srv-file-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srv-path-input {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srv-file-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.srv-file-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { store, updateSettings } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { authSession } from "@core/auth";
|
import { authSession, loginUI, logout } from "@core/auth";
|
||||||
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
|
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
|
||||||
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
|
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
|
||||||
|
|
||||||
@@ -33,72 +33,85 @@
|
|||||||
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
|
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
|
||||||
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
|
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
|
||||||
|
|
||||||
|
function normalizeAuthMode(mode: string): "NONE" | "BASIC_AUTH" | "UI_LOGIN" {
|
||||||
|
if (mode === "BASIC_AUTH" || mode === "UI_LOGIN" || mode === "NONE") return mode;
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
|
||||||
function showSaved(key: string) {
|
function showSaved(key: string) {
|
||||||
secSaved = key; secError = null;
|
secSaved = key; secError = null;
|
||||||
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
|
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!secLoaded) { secLoaded = true; loadServerSecurity(); }
|
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadServerSecurity() {
|
async function loadServerSecurity() {
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ settings: {
|
const res = await gql<{ settings: {
|
||||||
authMode: string; authUsername: string;
|
authMode: string; authUsername: string;
|
||||||
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
|
||||||
socksProxyVersion: number; socksProxyUsername: string;
|
socksProxyVersion: number; socksProxyUsername: string;
|
||||||
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
|
||||||
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
|
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
|
||||||
flareSolverrAsResponseFallback: boolean;
|
flareSolverrAsResponseFallback: boolean;
|
||||||
}}>(GET_SERVER_SECURITY);
|
}}>(GET_SERVER_SECURITY);
|
||||||
const s = res.settings;
|
const s = res.settings;
|
||||||
authMode = store.settings.serverAuthMode ?? "NONE";
|
const serverMode = normalizeAuthMode(s.authMode);
|
||||||
authUsername = s.authUsername || store.settings.serverAuthUser || "";
|
if (serverMode !== "UI_LOGIN") authSession.clearTokens();
|
||||||
updateSettings({ serverAuthUser: authUsername });
|
authMode = serverMode;
|
||||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
authUsername = s.authUsername || "";
|
||||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername });
|
||||||
socksUsername = s.socksProxyUsername;
|
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
|
||||||
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
|
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
|
||||||
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
|
socksUsername = s.socksProxyUsername;
|
||||||
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
|
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
|
||||||
updateSettings({
|
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
|
||||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
|
||||||
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
updateSettings({
|
||||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
||||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
||||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||||
});
|
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||||
} catch {}
|
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||||
}
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAuth() {
|
async function saveAuth() {
|
||||||
if ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim())) {
|
if (authMode === "NONE") { await clearAuth(); return; }
|
||||||
|
if (!authUsername.trim() || !authPassword.trim()) {
|
||||||
secError = "Username and password are required"; return;
|
secError = "Username and password are required"; return;
|
||||||
}
|
}
|
||||||
secLoading = true; secError = null;
|
secLoading = true; secError = null;
|
||||||
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newUser = authMode !== "NONE" ? authUsername.trim() : "";
|
const newUser = authUsername.trim();
|
||||||
const newPass = authMode !== "NONE" ? authPassword.trim() : "";
|
const newPass = authPassword.trim();
|
||||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
authSession.clearTokens();
|
||||||
|
|
||||||
if (authMode === "UI_LOGIN") {
|
if (authMode === "UI_LOGIN") {
|
||||||
authSession.clearTokens();
|
await loginUI(newUser, newPass);
|
||||||
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
|
updateSettings({ serverAuthMode: "UI_LOGIN", serverAuthUser: newUser, serverAuthPass: "" });
|
||||||
} else if (authMode === "BASIC_AUTH") {
|
|
||||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
|
||||||
} else {
|
} else {
|
||||||
authSession.clearTokens();
|
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: newUser, serverAuthPass: newPass });
|
||||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||||
|
|
||||||
authPassword = "";
|
authPassword = "";
|
||||||
showSaved("auth");
|
showSaved("auth");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
const msg = e?.message ?? "Failed to save authentication settings";
|
||||||
secError = e?.message ?? "Failed to save authentication settings";
|
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg);
|
||||||
|
if (!authMismatch) {
|
||||||
|
authSession.clearTokens();
|
||||||
|
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||||
|
}
|
||||||
|
secError = authMismatch
|
||||||
|
? "Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration."
|
||||||
|
: msg;
|
||||||
} finally { secLoading = false; }
|
} finally { secLoading = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +236,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||||
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}>
|
disabled={secLoading || ((authMode === "BASIC_AUTH" || authMode === "UI_LOGIN") && (!authUsername.trim() || !authPassword.trim()))}>
|
||||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export interface Settings {
|
|||||||
discordRpc: boolean;
|
discordRpc: boolean;
|
||||||
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
||||||
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
||||||
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean;
|
||||||
preferredExtensionLang: string; keybinds: Keybinds;
|
preferredExtensionLang: string; keybinds: Keybinds;
|
||||||
idleTimeoutMin?: number; splashCards?: boolean;
|
idleTimeoutMin?: number; splashCards?: boolean;
|
||||||
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
|
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number;
|
||||||
@@ -143,7 +143,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
discordRpc: false,
|
discordRpc: false,
|
||||||
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
||||||
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
||||||
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
|
serverUrl: "http://localhost:4567", serverBinary: "", serverBinaryArgs: "", autoStartServer: true, suwayomiWebUI: false,
|
||||||
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
||||||
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
||||||
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
|
markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user