Compare commits

..

42 Commits

Author SHA1 Message Date
Youwes09 d9a9427e3b Chore: Port over Settings (Barely Works) 2026-05-24 20:31:46 -05:00
Youwes09 ae5d9748c7 Chore: Port over Home & Fix Suwayomi-Server Detection on Web 2026-05-24 12:09:29 -05:00
Youwes09 6c39ef538f Fix: Splashscreen Appears on Boot 2026-05-22 21:39:29 -05:00
Youwes09 081becdd60 Chore: Basic Layout/Chrome + Stubs (WIP) 2026-05-22 21:30:40 -05:00
Youwes09 c891cb349c Chore: Implement Server Adapters & Request Manager 2026-05-22 20:44:55 -05:00
Youwes09 8cef74bb98 Chore: Restructure Repository for SvelteKit 2026-05-22 04:04:59 -05:00
Shozikan bf071dcfc7 Chore: Merge pull request #92 from zerebos/feat/update-panel
Feat/update panel
2026-05-21 22:56:37 -05:00
Youwes09 da788e90ba Feat: Manual Binary-Selection (CSS-WIP) (#91) 2026-05-21 14:37:53 -05:00
Zerebos b0efb183e8 Poll when updating on server 2026-05-21 02:43:06 -04:00
Zerebos 745b6993de Actually grab status from server 2026-05-21 02:33:06 -04:00
Zerebos bd79169f71 Basic caching 2026-05-21 02:23:09 -04:00
Zerebos 6fccf02614 Single line stats 2026-05-21 02:12:21 -04:00
Zerebos fa7cfdc4e6 Use stats boxes on history page 2026-05-21 02:12:21 -04:00
Zerebos 9c614b38f8 More parity between panels 2026-05-21 02:12:21 -04:00
Zerebos 30e50b5a1b Match update cards to download items 2026-05-21 02:12:21 -04:00
Zerebos 8ef0a14363 Add tab icons 2026-05-21 02:12:21 -04:00
Zerebos 4e2ad6cae7 Hoist toolbar into Recent, add status bar, dim read chapters, split cover click 2026-05-21 02:12:15 -04:00
Zerebos 9e56b1176c Integrate updates into recent activity page 2026-05-21 02:12:07 -04:00
Zerebos d025d07e07 Fix updates page data flow 2026-05-21 02:12:01 -04:00
Zerebos f988641446 Add updates page scaffold 2026-05-21 02:11:20 -04:00
Youwes09 3dad4bc729 Feat: Re-Arrangement of Folders (#86) 2026-05-19 20:36:44 -05:00
Youwes09 1af21efebd Feat: Download Storage Threshold Warning (#88) 2026-05-19 20:07:21 -05:00
Youwes09 b7197a09a7 Fix: Attempt to Patch UI Login (Not-Working) 2026-05-19 19:25:43 -05:00
Youwes09 50dd8d7e35 Fix: Preserve Token for NONE Path 2026-05-19 18:55:44 -05:00
Youwes09 b2eaea6552 Merge branch 'main' of github.com:moku-project/Moku 2026-05-19 18:53:47 -05:00
Shozikan 35aae6d85a Chore: Merge PR (#78)
Rework authentication for smoother switching between servers and auth mode
2026-05-19 18:52:48 -05:00
Youwes09 28e5f5625e Merge branch 'fix/auth' 2026-05-19 18:15:00 -05:00
Zerebos b99e4d9a3d Cleanup logs 2026-05-19 02:32:34 -04:00
Youwes09 d5f50c6495 Merge branch 'main' of https://github.com/Youwes09/Moku 2026-05-18 00:32:04 -05:00
Youwes09 89cfa50aff Fix PKGBUILD (V2) 2026-05-18 00:30:33 -05:00
Youwes09 5e591411e4 Chore: Patch PKGBUILD for AUR (V1) 2026-05-17 16:50:40 -05:00
Youwes09 8aaaf2451a Fix: Added ServerBinary Off & Flatpak Patches 2026-05-17 16:27:57 -05:00
Zerebos 75cc767b58 Expiry formatting change 2026-05-17 04:11:33 -04:00
Zerebos d30c623200 Better formatting for dates 2026-05-17 04:08:46 -04:00
Zerebos 017e9bc6da Authenticated fetch jwt settings 2026-05-17 04:00:34 -04:00
Zerebos 3b8088a2bf Decode ISO-8601 2026-05-17 03:50:39 -04:00
Zerebos 2c5320dd1f Some debug logging 2026-05-17 03:31:17 -04:00
Zerebos 1e35f304b6 Add auth debug in devtools 2026-05-17 03:29:27 -04:00
Zerebos 61339ea006 Implement jwt with refresh 2026-05-17 03:04:23 -04:00
Youwes09 f161fc08a2 Chore: Post-Bump 0.9.4 2026-05-17 00:14:22 -05:00
Zerebos bee8117aac Don't introduce a new key 2026-05-16 00:01:21 -04:00
Zerebos 0bea9c22cb Rework auth to allow smooth switching 2026-05-15 23:50:19 -04:00
352 changed files with 19957 additions and 1507 deletions
+20 -8
View File
@@ -1,26 +1,33 @@
# --- Build Artifacts ---
node_modules/
suwayomi-raw/
suwayomi-windows.zip
dist/
dist-tauri/
target/
bin/
out/
# --- 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 +37,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
View File
@@ -0,0 +1 @@
engine-strict=true
+36 -17
View File
@@ -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=(
'4e7e48ea3332f66c840f2b633c7b3f49b535b144f1b6cfc8d63ead24fcab3684'
'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
@@ -68,14 +87,14 @@ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Tachidesk"
mkdir -p "$DATA_DIR"
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
sed -i \
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
-e 's|server\.webUIEnabled.*|server.webUIEnabled = false|' \
-e 's|server\.initialOpenInBrowserEnabled.*|server.initialOpenInBrowserEnabled = false|' \
-e 's|server\.systemTrayEnabled.*|server.systemTrayEnabled = false|' \
"$DATA_DIR/server.conf"
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"
@@ -87,12 +106,12 @@ export _JAVA_OPTIONS="-Djava.awt.headless=true"
export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true"
exec java \
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
-Djava.awt.headless=true \
-Dapple.awt.UIElement=true \
-Dsun.java2d.noddraw=true \
-Dsun.awt.disablegui=true \
-Dsuwayomi.tachidesk.config.server.rootDir="$DATA_DIR" \
-jar /usr/lib/moku/tachidesk/Suwayomi-Server.jar
LAUNCHER
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"
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"
}
+26 -157
View File
@@ -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).
[![Release](https://www.shieldcn.dev/github/release/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku/releases/latest)
![GitHub Downloads](https://www.shieldcn.dev/github/downloads/moku-project/Moku.svg?variant=outline&size=default)
[![Stars](https://www.shieldcn.dev/github/stars/moku-project/Moku.svg?variant=outline&size=default)](https://github.com/moku-project/Moku)
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=outline&size=default)](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, AZ, 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">
![Runs on Windows](https://www.shieldcn.dev/badge/Runs%20on-Windows-0078D4.svg?logo=windows&logoColor=fff)
![Runs on Linux](https://www.shieldcn.dev/badge/Runs%20on-Linux-FCC624.svg?logo=linux&logoColor=000)
![Runs on MacOS](https://www.shieldcn.dev/badge/Runs%20on-MacOS-000000.svg?mode=light&logo=apple&logoColor=fff)
</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.
[![Discord](https://www.shieldcn.dev/discord/members/x97hj8zR72.svg?variant=secondary&size=large)](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.
+4 -1
View File
@@ -138,7 +138,10 @@
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);
});
+20 -2
View File
@@ -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 }
@@ -19,4 +25,4 @@ export const GET_CHAPTERS = `
}
}
}
`;
`;
@@ -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` |
+629
View File
@@ -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,6 +1,6 @@
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>>();
@@ -18,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") {
@@ -33,7 +33,8 @@ 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 blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
@@ -147,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",
@@ -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>
@@ -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`;
@@ -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);
@@ -23,8 +24,11 @@ class DownloadStore {
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
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 });
}
@@ -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}

Some files were not shown because too many files have changed in this diff Show More