mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74790c3a0 | |||
| 0e93908bb2 | |||
| 074147f64f | |||
| f91b46cfa5 | |||
| 71ee4052f3 | |||
| 5e2114810e | |||
| b3fca70f27 | |||
| 68f25a2ea7 | |||
| 3d6b6430ed | |||
| 54307d4411 | |||
| f8f080eff3 | |||
| f41f8a9c22 | |||
| 8cef79b2b4 | |||
| 6c39ef538f | |||
| 081becdd60 | |||
| c891cb349c | |||
| 8cef74bb98 | |||
| bf071dcfc7 | |||
| da788e90ba | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 | |||
| 3dad4bc729 | |||
| 1af21efebd | |||
| b7197a09a7 | |||
| 50dd8d7e35 | |||
| b2eaea6552 | |||
| 35aae6d85a | |||
| 28e5f5625e | |||
| b99e4d9a3d | |||
| d5f50c6495 | |||
| 89cfa50aff | |||
| 5e591411e4 | |||
| 8aaaf2451a | |||
| 75cc767b58 | |||
| d30c623200 | |||
| 017e9bc6da | |||
| 3b8088a2bf | |||
| 2c5320dd1f | |||
| 1e35f304b6 | |||
| 61339ea006 | |||
| f161fc08a2 | |||
| 239960683b | |||
| 3b5efc85d0 | |||
| 7df3846e75 | |||
| 01f123f5be | |||
| 0e2371096b | |||
| 47ae80a7d2 | |||
| d98547d540 | |||
| 897ecfd316 | |||
| e3abc72f1b | |||
| 6b56db7cf2 | |||
| 93cedca6b5 | |||
| bee8117aac | |||
| 0bea9c22cb |
+21
-8
@@ -1,26 +1,34 @@
|
||||
# --- Build Artifacts ---
|
||||
node_modules/
|
||||
suwayomi-raw/
|
||||
suwayomi-windows.zip
|
||||
dist/
|
||||
dist-tauri/
|
||||
target/
|
||||
bin/
|
||||
out/
|
||||
notes/
|
||||
|
||||
# --- Nix ---
|
||||
.direnv/
|
||||
result
|
||||
result-*
|
||||
|
||||
# --- Logs ---
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# --- IDEs & OS ---
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -30,14 +38,19 @@ yarn-error.log*
|
||||
*.sln
|
||||
*.swp
|
||||
|
||||
# --- Tauri specific ---
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/gen/
|
||||
|
||||
# --- Flatpak build artifacts ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
build-dir/
|
||||
repo/
|
||||
dist/
|
||||
packaging/frontend-dist.tar.gz
|
||||
*.flatpak
|
||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||
./flatpak-builder
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=moku
|
||||
pkgver=0.9.3
|
||||
pkgver=0.9.4
|
||||
pkgrel=1
|
||||
pkgdesc="Native Linux manga reader frontend for Suwayomi-Server"
|
||||
arch=('x86_64')
|
||||
@@ -13,27 +13,46 @@ depends=(
|
||||
)
|
||||
makedepends=(
|
||||
'rust'
|
||||
'cargo'
|
||||
'nodejs'
|
||||
'pnpm'
|
||||
)
|
||||
optdepends=(
|
||||
'discord: Discord rich presence'
|
||||
)
|
||||
options=('!strip')
|
||||
source=(
|
||||
"$pkgname-$pkgver.tar.gz::https://github.com/moku-project/Moku/archive/refs/tags/v$pkgver.tar.gz"
|
||||
"Suwayomi-Server-v2.1.2087.jar::https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/v2.1.2087/Suwayomi-Server-v2.1.2087.jar"
|
||||
)
|
||||
noextract=("Suwayomi-Server-v2.1.2087.jar")
|
||||
sha256sums=(
|
||||
'e7f3d70c81af2afd9933aab55372a8b0122bfd201dcf6077a61f2c69990aecf9'
|
||||
'fc1c8268b812e70e56460c8930ca8ae83bcd30eea5903ddfef4e30a3a9a5c1cc'
|
||||
'f589a422674252394c13b289a9c8be691905bf583efb7f4d5f1501ae5e91e6b3'
|
||||
)
|
||||
b2sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd "Moku-$pkgver"
|
||||
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() {
|
||||
cd "Moku-$pkgver"
|
||||
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 \
|
||||
--release \
|
||||
--manifest-path src-tauri/Cargo.toml
|
||||
@@ -52,7 +71,7 @@ package() {
|
||||
cat > "$pkgdir/usr/lib/moku/tachidesk/default-conf/server.conf" << 'CONF'
|
||||
server.ip = "127.0.0.1"
|
||||
server.port = 4567
|
||||
server.webUIEnabled = true
|
||||
server.webUIEnabled = false
|
||||
server.initialOpenInBrowserEnabled = false
|
||||
server.systemTrayEnabled = false
|
||||
server.downloadAsCbz = true
|
||||
@@ -105,6 +124,6 @@ LAUNCHER
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/io.github.moku_project.Moku.png"
|
||||
install -Dm644 packaging/io.github.moku_project.Moku.metainfo.xml \
|
||||
"$pkgdir/usr/share/metainfo/io.github.moku_project.Moku.metainfo.xml"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 LICENSE \
|
||||
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
|
||||
@@ -1,173 +1,42 @@
|
||||
<div align="center">
|
||||
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||
</div>
|
||||
# sv
|
||||
|
||||
<div align="center">
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
[](https://github.com/moku-project/Moku/releases/latest)
|
||||

|
||||
[](https://github.com/moku-project/Moku)
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
## Creating a project
|
||||
|
||||
</div>
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
<br/>
|
||||
|
||||
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a lightweight Tauri app — no Electron overhead.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Home.png" width="100%" alt="Home" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshots/Moku-Search.png" width="49%" alt="Search" />
|
||||
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="Tag Search" />
|
||||
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||
<img src="docs/screenshots/Moku-Downloads.png" width="49%" alt="Downloads" />
|
||||
<img src="docs/screenshots/Moku-ReaderSettings.png" width="49%" alt="Reader Settings" />
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots" style="color: #a8c4a8;">View all screenshots →</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||
- **Per-folder sorting & filtering** — each folder has its own independent sort (unread, A–Z, recently read, latest chapter, and more) and publication status filter (Ongoing, Completed, Hiatus, etc.)
|
||||
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||
- **Markers** — pin color-coded notes to any page while reading; markers appear as dots on the progress bar and are browseable under Series Detail → Manage → Markers
|
||||
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||
- **Automation** — pre-download titles automatically and optionally delete chapters after they're marked as read (accessible from Series Detail)
|
||||
- **Discord Rich Presence** — shows the manga title, current chapter, and an elapsed timer in your Discord status; configurable in Settings → General
|
||||
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||
- **Auto-updates** — in-app update checker with silent background notifications
|
||||
- **Improved NSFW filtering** — expanded tag parser gives the Hide NSFW setting better coverage across sources
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### Windows
|
||||
|
||||
**winget:**
|
||||
|
||||
```powershell
|
||||
winget install Moku.Moku
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
> Thanks to [@frozenKelp](https://github.com/frozenKelp) for setting up and maintaining the winget package through v0.9.0.
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
Or download the `.exe` installer from the [releases page](https://github.com/moku-project/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||
|
||||
### Linux (Flatpak, recommended)
|
||||
|
||||
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||
|
||||
```bash
|
||||
flatpak install io.github.moku_app.Moku
|
||||
```sh
|
||||
# recreate this project
|
||||
pnpm dlx sv@0.15.3 create --template minimal --types ts --install pnpm .
|
||||
```
|
||||
|
||||
Or download the latest `moku.flatpak` from the [releases page](https://github.com/moku-project/Moku/releases/latest) and install manually:
|
||||
## Developing
|
||||
|
||||
```bash
|
||||
flatpak install moku.flatpak
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
### Nix
|
||||
## Building
|
||||
|
||||
```bash
|
||||
nix run github:moku-project/Moku
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Add to your flake:
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:moku-project/Moku";
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
Download the `.dmg` from the [releases page](https://github.com/moku-project/Moku/releases/latest).
|
||||
|
||||
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||
> ```bash
|
||||
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||
|
||||
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/moku-project/Moku
|
||||
cd Moku
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
Or with Nix:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm tauri:dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
Questions, feedback, or just want to hang out — join the Discord.
|
||||
|
||||
[](https://discord.gg/x97hj8zR72)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { defaultWindowIcon } from "@tauri-apps/api/app";
|
||||
import { TrayIcon } from "@tauri-apps/api/tray";
|
||||
import { Menu } from "@tauri-apps/api/menu";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { store, updateSettings, setActiveDownloads } from "@store/state.svelte";
|
||||
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
|
||||
@@ -48,8 +45,13 @@
|
||||
}
|
||||
|
||||
async function doQuit() {
|
||||
if (store.settings.autoStartServer) await invoke("kill_server").catch(() => {});
|
||||
await win.destroy();
|
||||
if (store.settings.autoStartServer) {
|
||||
await Promise.race([
|
||||
invoke("kill_server").catch(() => {}),
|
||||
new Promise(res => setTimeout(res, 2000)),
|
||||
]);
|
||||
}
|
||||
await invoke("exit_app");
|
||||
}
|
||||
|
||||
async function doHide() {
|
||||
@@ -89,6 +91,13 @@
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!appReady) return;
|
||||
downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
return () => clearInterval(dlInterval);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.settings.discordRpc) {
|
||||
initRpc();
|
||||
@@ -123,60 +132,28 @@
|
||||
applyZoom();
|
||||
});
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: [
|
||||
{
|
||||
id: "show",
|
||||
text: "Show Moku",
|
||||
action: async () => {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "quit",
|
||||
text: "Quit",
|
||||
action: doQuit,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await TrayIcon.new({
|
||||
icon: await defaultWindowIcon(),
|
||||
menu,
|
||||
menuOnLeftClick: false,
|
||||
tooltip: "Moku",
|
||||
action: async (e) => {
|
||||
if (e.type === "Click") {
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unlistenClose = await win.listen("tauri://close-requested", handleCloseRequested);
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
if (store.settings.autoStartServer) {
|
||||
invoke<void>("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => {
|
||||
invoke<void>("spawn_server", {
|
||||
binary: store.settings.serverBinary,
|
||||
webUiEnabled: store.settings.suwayomiWebUI ?? false,
|
||||
}).catch((err: any) => {
|
||||
if (err?.kind === "NotConfigured") boot.notConfigured = true;
|
||||
else console.warn("Could not start server:", err);
|
||||
});
|
||||
}
|
||||
|
||||
await initStore();
|
||||
startProbe();
|
||||
|
||||
const unlistenDownload = await listen<{ chapterId: number; mangaId: number; progress: number }[]>(
|
||||
"download-progress",
|
||||
e => setActiveDownloads(e.payload),
|
||||
);
|
||||
|
||||
await downloadStore.poll();
|
||||
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
|
||||
|
||||
return () => {
|
||||
stopProbe();
|
||||
clearInterval(dlInterval);
|
||||
unlistenResize();
|
||||
unlistenScale();
|
||||
unlistenDownload();
|
||||
@@ -215,7 +192,7 @@
|
||||
{/if}
|
||||
|
||||
<div id="app-shell" class="root">
|
||||
{#if !store.activeChapter}<TitleBar />{/if}
|
||||
{#if !store.activeChapter}<TitleBar onClose={handleCloseRequested} />{/if}
|
||||
<div class="content">
|
||||
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { getBlobUrl } from "@core/cache/imageCache";
|
||||
|
||||
@@ -104,6 +104,15 @@ export async function gql<T>(
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): 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 res = await fetchWithRetry(
|
||||
`${getServerUrl()}/api/graphql`,
|
||||
@@ -111,12 +120,21 @@ export async function gql<T>(
|
||||
signal,
|
||||
);
|
||||
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();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) {
|
||||
const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message));
|
||||
if (isAuthError && !boot.skipped) {
|
||||
const retried = await tryRefreshAndRetry();
|
||||
if (retried) return retried;
|
||||
|
||||
boot.sessionExpired = true;
|
||||
boot.loginRequired = true;
|
||||
boot.loginUser = store.settings.serverAuthUser ?? "";
|
||||
@@ -108,15 +108,20 @@ export const PUSH_KOSYNC_PROGRESS = `
|
||||
`;
|
||||
|
||||
export const LOGIN_USER = `
|
||||
mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
mutation Login($username: String!, $password: String!, $clientMutationId: String) {
|
||||
login(input: { username: $username, password: $password, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REFRESH_TOKEN = `
|
||||
mutation RefreshToken {
|
||||
refreshToken(input: {}) { accessToken }
|
||||
mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||
accessToken
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,6 +2,12 @@ export const GET_RECENTLY_UPDATED = `
|
||||
query GetRecentlyUpdated {
|
||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
chapterNumber
|
||||
sourceOrder
|
||||
isRead
|
||||
lastPageRead
|
||||
mangaId
|
||||
fetchedAt
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
@@ -22,7 +22,7 @@ export const GET_SOURCES = `
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest baseUrl
|
||||
isConfigurable supportsLatest
|
||||
extension { pkgName }
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const GET_MIGRATABLE_SOURCES = `
|
||||
nodes {
|
||||
sourceId
|
||||
source {
|
||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl
|
||||
id name lang displayName iconUrl isNsfw isConfigurable supportsLatest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,9 @@ export const LIBRARY_UPDATE_STATUS = `
|
||||
manga { id title thumbnailUrl unreadCount }
|
||||
}
|
||||
}
|
||||
lastUpdateTimestamp {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
||||
| `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
|
||||
| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing |
|
||||
| `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` |
|
||||
| `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers |
|
||||
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||
@@ -1,32 +1,30 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
|
||||
/**
|
||||
* {@attach selectPortal(triggerEl)}
|
||||
*
|
||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
||||
*
|
||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
||||
*/
|
||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||
return (menuEl: HTMLElement) => {
|
||||
// Position & move to body
|
||||
function position() {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
|
||||
const top = r.bottom / zoom + 4;
|
||||
const right = r.right / zoom;
|
||||
const width = menuEl.offsetWidth;
|
||||
const left = Math.max(8, right - width);
|
||||
|
||||
menuEl.style.position = "fixed";
|
||||
menuEl.style.top = `${r.bottom + 4}px`;
|
||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||
// clamp to viewport left edge
|
||||
const left = parseFloat(menuEl.style.left);
|
||||
if (left < 8) menuEl.style.left = "8px";
|
||||
menuEl.style.top = `${top}px`;
|
||||
menuEl.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
menuEl.style.visibility = "hidden";
|
||||
document.body.appendChild(menuEl);
|
||||
triggerEl.__selectMenuEl = menuEl;
|
||||
position();
|
||||
|
||||
// Reposition on scroll / resize while open
|
||||
requestAnimationFrame(() => {
|
||||
position();
|
||||
menuEl.style.visibility = "";
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", position, true);
|
||||
window.addEventListener("resize", position);
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN";
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(msg = "Authentication required") {
|
||||
super(msg);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "moku_access_token";
|
||||
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 = {
|
||||
getSession: () => {
|
||||
const base = getServerBase();
|
||||
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 = {
|
||||
clearTokens() {
|
||||
_refreshPromise = null;
|
||||
_jwtSettings = null;
|
||||
_jwtSettingsBase = null;
|
||||
_jwtSettingsFetchedAt = 0;
|
||||
uiAuth.clearToken();
|
||||
},
|
||||
hasSession(): boolean {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") return uiAuth.getSession() !== null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function 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 {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
function bearerHeader(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||
return JSON.stringify({ query, ...(variables ? { variables } : {}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
skipped = false,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const baseHeaders = (init.headers ?? {}) as Record<string, string>;
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = await getUIAccessToken();
|
||||
if (!token) {
|
||||
if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders });
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
let res = await fetch(url, {
|
||||
...init, signal, credentials: "omit",
|
||||
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" });
|
||||
}
|
||||
|
||||
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> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: gqlBody(
|
||||
`mutation Login($username: String!, $password: String!) {
|
||||
login(input: { username: $username, password: $password }) {
|
||||
accessToken
|
||||
refreshToken
|
||||
clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ username: user, password: pass },
|
||||
),
|
||||
signal: timeoutSignal(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login request failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
const payload = json?.data?.login;
|
||||
const accessToken: string | undefined = payload?.accessToken;
|
||||
const refreshToken: string | undefined = payload?.refreshToken;
|
||||
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> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST", credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
uiAuth.clearToken();
|
||||
updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
const token = mode === "UI_LOGIN" ? await getUIAccessToken() : null;
|
||||
|
||||
if (mode === "UI_LOGIN" && !token) return "auth_required";
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
} else if (mode === "UI_LOGIN" && token) {
|
||||
Object.assign(headers, bearerHeader(token));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST", credentials: "omit", headers,
|
||||
body: gqlBody("{ __typename }"),
|
||||
signal: timeoutSignal(5000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
if (res.status === 401) return "auth_required";
|
||||
return "unreachable";
|
||||
} catch {
|
||||
return "unreachable";
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { uiAuth } from "@core/auth";
|
||||
import { getUIAccessToken } from "@core/auth";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
let drainScheduled = false;
|
||||
let clearing = false;
|
||||
|
||||
interface QueueEntry {
|
||||
url: string;
|
||||
@@ -17,10 +18,10 @@ interface QueueEntry {
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
if (mode === "UI_LOGIN") {
|
||||
const token = uiAuth.getToken();
|
||||
const token = await getUIAccessToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
if (mode === "BASIC_AUTH") {
|
||||
@@ -32,9 +33,12 @@ function getAuthHeaders(): Record<string, 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}`);
|
||||
const blobUrl = URL.createObjectURL(await res.blob());
|
||||
const blob = await res.blob();
|
||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
cache.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
}
|
||||
@@ -121,8 +125,10 @@ export function cancelQueuedFetches(): void {
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
clearing = true;
|
||||
cancelQueuedFetches();
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
clearing = false;
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
interface MemEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class MemoryCache<T> {
|
||||
readonly #cap: number;
|
||||
readonly #ttl: number;
|
||||
readonly #map = new Map<string, MemEntry<T>>();
|
||||
|
||||
constructor(capacity: number, ttlMs: number) {
|
||||
this.#cap = capacity;
|
||||
this.#ttl = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||
this.#map.delete(key);
|
||||
this.#map.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
if (this.#map.has(key)) this.#map.delete(key);
|
||||
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return false;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(key: string): void { this.#map.delete(key); }
|
||||
|
||||
clear(): void { this.#map.clear(); }
|
||||
|
||||
get size(): number { return this.#map.size; }
|
||||
}
|
||||
@@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||
}
|
||||
|
||||
export function preloadImage(url: string, useBlob: boolean): void {
|
||||
if (useBlob) {
|
||||
preloadBlobUrls([url], 0);
|
||||
return;
|
||||
}
|
||||
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
|
||||
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
groups.get(tag)!.add(key);
|
||||
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||
keyToGroups.get(key)!.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterKey(key: string) {
|
||||
const tags = keyToGroups.get(key);
|
||||
if (tags) {
|
||||
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||
keyToGroups.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +40,20 @@ export const cache = {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
}) as Promise<T>;
|
||||
store.set(key, { promise, fetchedAt: Date.now() });
|
||||
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||
registerGroups(key, group);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T, group?: string | string[]) {
|
||||
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
store.set(key, {
|
||||
promise: Promise.resolve(value),
|
||||
fetchedAt: Date.now(),
|
||||
fetcher: existing?.fetcher,
|
||||
ttl: existing?.ttl,
|
||||
});
|
||||
registerGroups(key, group);
|
||||
notify(key);
|
||||
},
|
||||
@@ -43,10 +62,38 @@ export const cache = {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing) return;
|
||||
const next = existing.promise.then(fn);
|
||||
store.set(key, { promise: next, fetchedAt: Date.now() });
|
||||
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||
next.then(() => notify(key)).catch(() => {});
|
||||
},
|
||||
|
||||
refresh<T>(key: string): Promise<T> | undefined {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing?.fetcher) return undefined;
|
||||
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
refreshGroup(tag: string): void {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of [...keys]) {
|
||||
const existing = store.get(key);
|
||||
if (existing?.fetcher) {
|
||||
const promise = existing.fetcher().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
has(key: string): boolean { return store.has(key); },
|
||||
|
||||
ageOf(key: string): number | undefined {
|
||||
@@ -54,18 +101,35 @@ export const cache = {
|
||||
return e ? Date.now() - e.fetchedAt : undefined;
|
||||
},
|
||||
|
||||
clear(key: string) { store.delete(key); notify(key); },
|
||||
isStale(key: string): boolean {
|
||||
const e = store.get(key);
|
||||
if (!e) return true;
|
||||
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||
},
|
||||
|
||||
clear(key: string) {
|
||||
unregisterKey(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
},
|
||||
|
||||
clearGroup(tag: string) {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of keys) { store.delete(key); notify(key); }
|
||||
for (const key of [...keys]) {
|
||||
keyToGroups.get(key)?.delete(tag);
|
||||
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
}
|
||||
groups.delete(tag);
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
const allKeys = [...store.keys()];
|
||||
store.clear(); groups.clear();
|
||||
store.clear();
|
||||
groups.clear();
|
||||
keyToGroups.clear();
|
||||
allKeys.forEach(notify);
|
||||
},
|
||||
|
||||
@@ -83,6 +147,7 @@ export const CACHE_GROUPS = {
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
RECENT_UPDATES: "recent_updates",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga",
|
||||
@@ -161,7 +226,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||
}
|
||||
|
||||
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||
cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
|
||||
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
|
||||
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import DownloadItem from "./DownloadItem.svelte";
|
||||
import type { DownloadQueueItem } from "@types/index";
|
||||
|
||||
interface Props {
|
||||
queue: DownloadQueueItem[];
|
||||
loading: boolean;
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="list">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="sk-row">
|
||||
<div class="sk-thumb skeleton"></div>
|
||||
|
||||
<div class="sk-info">
|
||||
<div class="skeleton sk-title"></div>
|
||||
<div class="skeleton sk-chapter"></div>
|
||||
<div class="sk-progress-row">
|
||||
<div class="skeleton sk-bar"></div>
|
||||
<div class="skeleton sk-pages"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sk-right">
|
||||
<div class="skeleton sk-state"></div>
|
||||
<div class="sk-actions">
|
||||
<div class="skeleton sk-btn"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="empty">Queue is empty.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each queue as item, i (item.chapter.id)}
|
||||
<DownloadItem
|
||||
{item}
|
||||
isActive={i === 0 && isRunning}
|
||||
isRemoving={dequeueing.has(item.chapter.id)}
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
|
||||
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
|
||||
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
|
||||
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
|
||||
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
|
||||
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sk-bar { flex: 1; height: 2px; }
|
||||
.sk-pages { width: 28px; height: 9px; }
|
||||
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.sk-state { width: 54px; height: 9px; }
|
||||
.sk-actions { display: flex; gap: 2px; }
|
||||
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
+12
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
|
||||
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 {
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
+65
-1
@@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||
import {
|
||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||
isRunning, getErrored, calcSpeed, estimateEta,
|
||||
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||
type SpeedSample,
|
||||
} from "../lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
@@ -25,6 +26,9 @@ class DownloadStore {
|
||||
batchWorking = $state(false);
|
||||
pagesPerSec: 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 autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||
@@ -82,6 +86,52 @@ class DownloadStore {
|
||||
this.status = ds;
|
||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||
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) {
|
||||
@@ -172,11 +222,21 @@ class DownloadStore {
|
||||
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) {
|
||||
if (this.dequeueing.has(chapterId)) return;
|
||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||
try {
|
||||
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 });
|
||||
this.poll();
|
||||
} catch (e) { console.error(e); this.poll(); }
|
||||
@@ -189,6 +249,8 @@ class DownloadStore {
|
||||
const ids = [...this.erroredIds];
|
||||
try {
|
||||
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 });
|
||||
this.poll();
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
@@ -204,6 +266,8 @@ class DownloadStore {
|
||||
try {
|
||||
if (ids.length > 0) {
|
||||
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 });
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
+10
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
|
||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||
|
||||
interface Props {
|
||||
@@ -40,6 +40,15 @@
|
||||
{/if}
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
||||
{#if f.id === "installed"}
|
||||
<CheckCircle size={11} weight="bold" />
|
||||
{:else if f.id === "available"}
|
||||
<Globe size={11} weight="bold" />
|
||||
{:else if f.id === "updates"}
|
||||
<ArrowCircleUp size={11} weight="bold" />
|
||||
{:else if f.id === "all"}
|
||||
<Rows size={11} weight="bold" />
|
||||
{/if}
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
+101
-8
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap } from "phosphor-svelte";
|
||||
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "@core/cover/coverResolver";
|
||||
import { gql } from "@api/client";
|
||||
@@ -29,14 +29,24 @@
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
|
||||
type ContentFilter = "unread" | "downloaded";
|
||||
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
|
||||
let filterOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
|
||||
|
||||
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
|
||||
|
||||
const allManga = $derived(groups.flatMap(g => g.manga));
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? allManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase()))
|
||||
: allManga
|
||||
);
|
||||
|
||||
const filtered = $derived((() => {
|
||||
let items = allManga;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||||
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
|
||||
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
|
||||
return items;
|
||||
})());
|
||||
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
|
||||
@@ -56,6 +66,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter(f: ContentFilter) {
|
||||
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters = {};
|
||||
}
|
||||
|
||||
function openMigrate(group: SourceLibrary) {
|
||||
const node = sourceNodes.find(s => s.id === group.sourceId);
|
||||
migrateTarget = {
|
||||
@@ -65,6 +83,20 @@
|
||||
manga: group.manga,
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!filterOpen) return;
|
||||
function onOutside(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
|
||||
}
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||
return () => document.removeEventListener("mousedown", onOutside, true);
|
||||
});
|
||||
|
||||
const CONTENT_FILTERS: [ContentFilter, string][] = [
|
||||
["unread", "Unread"],
|
||||
["downloaded", "Downloaded"],
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
@@ -80,18 +112,57 @@
|
||||
<span class="title">{extensionName}</span>
|
||||
</div>
|
||||
{#if !loading}
|
||||
<span class="count-badge">{allManga.length}</span>
|
||||
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
|
||||
{/if}
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="filter-wrap">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:filter-btn-active={hasActiveFilters}
|
||||
title="Filter"
|
||||
onclick={() => filterOpen = !filterOpen}
|
||||
>
|
||||
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
|
||||
</button>
|
||||
{#if filterOpen}
|
||||
<div class="filter-panel" role="menu">
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Filter</span>
|
||||
{#if hasActiveFilters}
|
||||
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Content</p>
|
||||
{#each CONTENT_FILTERS as [f, label]}
|
||||
<button
|
||||
class="panel-item"
|
||||
class:panel-item-active={activeFilters[f]}
|
||||
role="menuitem"
|
||||
onclick={() => toggleFilter(f)}
|
||||
>
|
||||
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
|
||||
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sources.length > 0}
|
||||
<button class="header-btn" onclick={onSettings} title="Extension settings">
|
||||
<GearSix size={14} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
@@ -181,6 +252,7 @@
|
||||
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
|
||||
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
@@ -191,12 +263,31 @@
|
||||
|
||||
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; margin-left: auto; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
.filter-wrap { position: relative; }
|
||||
.filter-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||
.panel-clear-btn:hover { color: var(--color-error); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||
.panel-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
|
||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
|
||||
.panel-check-on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
|
||||
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
@@ -237,4 +328,6 @@
|
||||
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
|
||||
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user