[V1] Flatpak Release + Buffering Fix & Storage Management
@@ -32,4 +32,10 @@ yarn-error.log*
|
||||
|
||||
# --- Tauri specific ---
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/gen/
|
||||
|
||||
# --- Flatpak build artifacts ---
|
||||
build-dir/
|
||||
repo/
|
||||
*.flatpak
|
||||
.flatpak-builder/
|
||||
@@ -0,0 +1,90 @@
|
||||
app-id: dev.moku.app
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '47'
|
||||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: moku
|
||||
separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --filesystem=xdg-data/moku:create
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
env:
|
||||
CARGO_HOME: /run/build/moku/cargo
|
||||
RUSTFLAGS: ''
|
||||
|
||||
modules:
|
||||
|
||||
- name: openjdk
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p /app/jre
|
||||
- tar -xf jdk.tar.gz -C /app/jre --strip-components=1
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.3%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.3_9.tar.gz
|
||||
sha256: f1af100c4afca2035f446967323230150cfe5872b5a664d98c86963e5c066e0d
|
||||
dest-filename: jdk.tar.gz
|
||||
|
||||
- name: tachidesk-server
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p /app/tachidesk /app/bin
|
||||
- cp Suwayomi-Server.jar /app/tachidesk/
|
||||
- |
|
||||
cat > /app/bin/tachidesk-server << 'EOF'
|
||||
#!/bin/sh
|
||||
exec /app/jre/bin/java -jar /app/tachidesk/Suwayomi-Server.jar "$@"
|
||||
EOF
|
||||
- chmod +x /app/bin/tachidesk-server
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/suwayomi-server-v2.1.1867.jar
|
||||
sha256: 51e307c2581e4e1a002991ab3e3a77503c8b074c42695987a984a7382d0ac5af
|
||||
dest-filename: Suwayomi-Server.jar
|
||||
|
||||
- name: moku
|
||||
buildsystem: simple
|
||||
|
||||
build-options:
|
||||
env:
|
||||
CARGO_HOME: /run/build/moku/cargo
|
||||
XDG_DATA_HOME: /run/build/moku/xdg-data
|
||||
TAURI_SKIP_DEVSERVER_CHECK: 'true'
|
||||
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig
|
||||
|
||||
build-commands:
|
||||
- tar -xzf frontend-dist.tar.gz
|
||||
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
|
||||
- install -Dm755 src-tauri/target/release/moku /app/bin/moku
|
||||
- install -Dm644 packaging/dev.moku.app.desktop /app/share/applications/dev.moku.app.desktop
|
||||
- install -Dm644 src-tauri/icons/32x32.png /app/share/icons/hicolor/32x32/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128.png /app/share/icons/hicolor/128x128/apps/dev.moku.app.png
|
||||
- install -Dm644 src-tauri/icons/128x128@2x.png /app/share/icons/hicolor/256x256/apps/dev.moku.app.png
|
||||
- install -Dm644 packaging/dev.moku.app.metainfo.xml /app/share/metainfo/dev.moku.app.metainfo.xml
|
||||
|
||||
sources:
|
||||
- type: dir
|
||||
path: .
|
||||
- type: file
|
||||
path: packaging/frontend-dist.tar.gz
|
||||
sha256: 386b393cd29f84064a3abef926237cb8a028da49c930a24ead7ad8a67d671a9c
|
||||
- packaging/cargo-sources.json
|
||||
- type: inline
|
||||
dest: src-tauri/.cargo
|
||||
dest-filename: config.toml
|
||||
contents: |
|
||||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source.vendored-sources]
|
||||
directory = "/run/build/moku/cargo/vendor"
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Moku
|
||||
Comment=Manga reader powered by Suwayomi
|
||||
Exec=moku
|
||||
Icon=dev.moku.app
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Viewer;
|
||||
Keywords=manga;comics;reader;
|
||||
StartupWMClass=moku
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>dev.moku.app</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>Moku</name>
|
||||
<summary>Manga reader powered by Suwayomi</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Moku is a desktop manga reader built on top of Suwayomi-Server (Tachidesk),
|
||||
providing a clean native interface for browsing, reading, and managing your
|
||||
manga library across hundreds of sources.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">dev.moku.app.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/shozikan/Moku</url>
|
||||
<url type="bugtracker">https://github.com/shozikan/Moku/issues</url>
|
||||
|
||||
<provides>
|
||||
<binary>moku</binary>
|
||||
</provides>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2025-01-01">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = [
|
||||
# "aiohttp<4.0.0,>=3.9.5",
|
||||
# "PyYAML<7.0.0,>=6.0.2",
|
||||
# "tomlkit>=0.13.3,<1.0"
|
||||
# ]
|
||||
# ///
|
||||
|
||||
__license__ = "MIT"
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
)
|
||||
from urllib.parse import ParseResult, parse_qs, urlparse
|
||||
|
||||
import aiohttp
|
||||
import tomlkit
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
YAML_AVAIL = True
|
||||
except ImportError:
|
||||
YAML_AVAIL = False
|
||||
|
||||
if TYPE_CHECKING and not YAML_AVAIL:
|
||||
import yaml
|
||||
|
||||
CRATES_IO = "https://static.crates.io/crates"
|
||||
CARGO_HOME = "cargo"
|
||||
CARGO_CRATES = f"{CARGO_HOME}/vendor"
|
||||
VENDORED_SOURCES = "vendored-sources"
|
||||
GIT_CACHE = "flatpak-cargo/git"
|
||||
COMMIT_LEN = 7
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def workdir(path: str) -> Iterator[None]:
|
||||
oldpath = os.getcwd()
|
||||
os.chdir(path)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(oldpath)
|
||||
|
||||
|
||||
def canonical_url(url: str) -> ParseResult:
|
||||
"Converts a string to a Cargo Canonical URL, as per https://github.com/rust-lang/cargo/blob/35c55a93200c84a4de4627f1770f76a8ad268a39/src/cargo/util/canonical_url.rs#L19"
|
||||
# Hrm. The upstream cargo does not replace those URLs, but if we don't then it doesn't work too well :(
|
||||
url = url.replace("git+https://", "https://")
|
||||
u = urlparse(url)
|
||||
# It seems cargo drops query and fragment
|
||||
u = ParseResult(u.scheme, u.netloc, u.path, "", "", "")
|
||||
u = u._replace(path=u.path.rstrip("/"))
|
||||
|
||||
if u.netloc == "github.com":
|
||||
u = u._replace(scheme="https")
|
||||
u = u._replace(path=u.path.lower())
|
||||
|
||||
if u.path.endswith(".git"):
|
||||
u = u._replace(path=u.path[: -len(".git")])
|
||||
|
||||
return u
|
||||
|
||||
|
||||
def get_git_tarball(repo_url: str, commit: str) -> str:
|
||||
url = canonical_url(repo_url)
|
||||
path = url.path.split("/")[1:]
|
||||
|
||||
assert len(path) == 2
|
||||
owner = path[0]
|
||||
if path[1].endswith(".git"):
|
||||
repo = path[1].replace(".git", "")
|
||||
else:
|
||||
repo = path[1]
|
||||
if url.hostname == "github.com":
|
||||
return f"https://codeload.{url.hostname}/{owner}/{repo}/tar.gz/{commit}"
|
||||
elif url.hostname.split(".")[0] == "gitlab": # type: ignore
|
||||
return f"https://{url.hostname}/{owner}/{repo}/-/archive/{commit}/{repo}-{commit}.tar.gz"
|
||||
elif url.hostname == "bitbucket.org":
|
||||
return f"https://{url.hostname}/{owner}/{repo}/get/{commit}.tar.gz"
|
||||
else:
|
||||
raise ValueError(f"Don't know how to get tarball for {repo_url}")
|
||||
|
||||
|
||||
async def get_remote_sha256(url: str) -> str:
|
||||
logging.info(f"started sha256({url})")
|
||||
sha256 = hashlib.sha256()
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as http_session:
|
||||
async with http_session.get(url) as response:
|
||||
while True:
|
||||
data = await response.content.read(4096)
|
||||
if not data:
|
||||
break
|
||||
sha256.update(data)
|
||||
logging.info(f"done sha256({url})")
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
_TomlType = Dict[str, Any]
|
||||
|
||||
|
||||
def load_toml(tomlfile: str = "Cargo.lock") -> _TomlType:
|
||||
with open(tomlfile, "r", encoding="utf-8") as f:
|
||||
toml_data = tomlkit.parse(f.read()).unwrap()
|
||||
return toml_data
|
||||
|
||||
|
||||
def git_repo_name(git_url: str, commit: str) -> str:
|
||||
name = canonical_url(git_url).path.split("/")[-1]
|
||||
return f"{name}-{commit[:COMMIT_LEN]}"
|
||||
|
||||
|
||||
def fetch_git_repo(git_url: str, commit: str) -> str:
|
||||
repo_dir = git_url.replace("://", "_").replace("/", "_")
|
||||
cache_dir = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
||||
clone_dir = os.path.join(cache_dir, "flatpak-cargo", repo_dir)
|
||||
if not os.path.isdir(os.path.join(clone_dir, ".git")):
|
||||
subprocess.run(["git", "clone", "--depth=1", git_url, clone_dir], check=True)
|
||||
rev_parse_proc = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=clone_dir, check=True, stdout=subprocess.PIPE
|
||||
)
|
||||
head = rev_parse_proc.stdout.decode().strip()
|
||||
if head[:COMMIT_LEN] != commit[:COMMIT_LEN]:
|
||||
subprocess.run(["git", "fetch", "origin", commit], cwd=clone_dir, check=True)
|
||||
try:
|
||||
subprocess.run(["git", "checkout", commit], cwd=clone_dir, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
logging.info(
|
||||
"Checking out commit %s failed for %s. Trying to force checkout the requested commit",
|
||||
commit,
|
||||
git_url,
|
||||
)
|
||||
subprocess.run(["git", "checkout", "-f", commit], cwd=clone_dir, check=True)
|
||||
|
||||
# Get the submodules as they might contain dependencies. This is a noop if
|
||||
# there are no submodules in the repository
|
||||
subprocess.run(
|
||||
["git", "submodule", "update", "--init", "--recursive"],
|
||||
cwd=clone_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return clone_dir
|
||||
|
||||
|
||||
def update_workspace_keys(pkg: dict[str, Any], workspace: dict[str, Any]) -> None:
|
||||
for key, item in list(pkg.items()):
|
||||
# There cannot be a 'workspace' key if the item is not a dict.
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
# Recurse for keys under target.cfg(..)
|
||||
if key == "target":
|
||||
for target in item.values():
|
||||
update_workspace_keys(target, workspace)
|
||||
continue
|
||||
# dev-dependencies and build-dependencies should reference root dependencies table from workspace
|
||||
elif key == "dev-dependencies" or key == "build-dependencies":
|
||||
update_workspace_keys(item, workspace.get("dependencies", None))
|
||||
continue
|
||||
|
||||
if not workspace or key not in workspace:
|
||||
continue
|
||||
|
||||
workspace_item = workspace[key]
|
||||
|
||||
if "workspace" in item:
|
||||
if isinstance(workspace_item, dict):
|
||||
del item["workspace"]
|
||||
|
||||
for dep_key, workspace_value in workspace_item.items():
|
||||
# features are additive
|
||||
if dep_key == "features" and "features" in item:
|
||||
item["features"] += workspace_value
|
||||
else:
|
||||
item[dep_key] = workspace_value
|
||||
elif len(item) > 1:
|
||||
del item["workspace"]
|
||||
item.update({"version": workspace_item})
|
||||
else:
|
||||
pkg[key] = workspace_item
|
||||
else:
|
||||
update_workspace_keys(item, workspace_item)
|
||||
|
||||
|
||||
class _GitPackage(NamedTuple):
|
||||
path: str
|
||||
package: _TomlType
|
||||
workspace: Optional[_TomlType]
|
||||
|
||||
@property
|
||||
def normalized(self) -> _TomlType:
|
||||
package = copy.deepcopy(self.package)
|
||||
if self.workspace is None:
|
||||
return package
|
||||
|
||||
update_workspace_keys(package, self.workspace)
|
||||
|
||||
return package
|
||||
|
||||
|
||||
_GitPackagesType = Dict[str, _GitPackage]
|
||||
|
||||
|
||||
async def get_git_repo_packages(git_url: str, commit: str) -> _GitPackagesType:
|
||||
logging.info("Loading packages from %s", git_url)
|
||||
git_repo_dir = fetch_git_repo(git_url, commit)
|
||||
packages: _GitPackagesType = {}
|
||||
|
||||
def get_cargo_toml_packages(
|
||||
root_dir: str, workspace: Optional[_TomlType] = None
|
||||
) -> None:
|
||||
assert not os.path.isabs(root_dir) and os.path.isdir(root_dir)
|
||||
|
||||
with workdir(root_dir):
|
||||
if os.path.exists("Cargo.toml"):
|
||||
cargo_toml = load_toml("Cargo.toml")
|
||||
workspace = cargo_toml.get("workspace") or workspace
|
||||
|
||||
if "package" in cargo_toml:
|
||||
packages[cargo_toml["package"]["name"]] = _GitPackage(
|
||||
path=os.path.normpath(root_dir),
|
||||
package=cargo_toml,
|
||||
workspace=workspace,
|
||||
)
|
||||
for child in os.scandir(root_dir):
|
||||
if child.is_dir():
|
||||
# the workspace can be referenced by any subdirectory
|
||||
get_cargo_toml_packages(child.path, workspace)
|
||||
|
||||
with workdir(git_repo_dir):
|
||||
get_cargo_toml_packages(".")
|
||||
|
||||
assert packages, f"No packages found in {git_repo_dir}"
|
||||
logging.debug(
|
||||
"Packages in %s:\n%s",
|
||||
git_url,
|
||||
json.dumps(
|
||||
{k: v.path for k, v in packages.items()},
|
||||
indent=4,
|
||||
),
|
||||
)
|
||||
return packages
|
||||
|
||||
|
||||
_FlatpakSourceType = Dict[str, Any]
|
||||
|
||||
|
||||
async def get_git_repo_sources(
|
||||
url: str,
|
||||
commit: str,
|
||||
tarball: bool = False,
|
||||
) -> List[_FlatpakSourceType]:
|
||||
name = git_repo_name(url, commit)
|
||||
if tarball:
|
||||
tarball_url = get_git_tarball(url, commit)
|
||||
git_repo_sources = [
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": tarball_url,
|
||||
"sha256": await get_remote_sha256(tarball_url),
|
||||
"dest": f"{GIT_CACHE}/{name}",
|
||||
}
|
||||
]
|
||||
else:
|
||||
git_repo_sources = [
|
||||
{
|
||||
"type": "git",
|
||||
"url": url,
|
||||
"commit": commit,
|
||||
"dest": f"{GIT_CACHE}/{name}",
|
||||
}
|
||||
]
|
||||
return git_repo_sources
|
||||
|
||||
|
||||
_GitRepo = TypedDict(
|
||||
"_GitRepo", {"lock": asyncio.Lock, "commits": Dict[str, _GitPackagesType]}
|
||||
)
|
||||
_GitReposType = Dict[str, _GitRepo]
|
||||
_VendorEntryType = Dict[str, Dict[str, str]]
|
||||
|
||||
|
||||
async def get_git_package_sources(
|
||||
package: _TomlType,
|
||||
git_repos: _GitReposType,
|
||||
) -> Tuple[List[_FlatpakSourceType], _VendorEntryType]:
|
||||
name = package["name"]
|
||||
source = package["source"]
|
||||
commit = urlparse(source).fragment
|
||||
assert commit, "The commit needs to be indicated in the fragement part"
|
||||
canonical = canonical_url(source)
|
||||
repo_url = canonical.geturl()
|
||||
|
||||
git_repo = git_repos.setdefault(
|
||||
repo_url,
|
||||
{
|
||||
"commits": {},
|
||||
"lock": asyncio.Lock(),
|
||||
},
|
||||
)
|
||||
async with git_repo["lock"]:
|
||||
if commit not in git_repo["commits"]:
|
||||
git_repo["commits"][commit] = await get_git_repo_packages(repo_url, commit)
|
||||
|
||||
cargo_vendored_entry: _VendorEntryType = {
|
||||
repo_url: {
|
||||
"git": repo_url,
|
||||
"replace-with": VENDORED_SOURCES,
|
||||
}
|
||||
}
|
||||
rev = parse_qs(urlparse(source).query).get("rev")
|
||||
tag = parse_qs(urlparse(source).query).get("tag")
|
||||
branch = parse_qs(urlparse(source).query).get("branch")
|
||||
if rev:
|
||||
assert len(rev) == 1
|
||||
cargo_vendored_entry[repo_url]["rev"] = rev[0]
|
||||
elif tag:
|
||||
assert len(tag) == 1
|
||||
cargo_vendored_entry[repo_url]["tag"] = tag[0]
|
||||
elif branch:
|
||||
assert len(branch) == 1
|
||||
cargo_vendored_entry[repo_url]["branch"] = branch[0]
|
||||
|
||||
logging.info("Adding package %s from %s", name, repo_url)
|
||||
git_pkg = git_repo["commits"][commit][name]
|
||||
pkg_repo_dir = os.path.join(
|
||||
GIT_CACHE, git_repo_name(repo_url, commit), git_pkg.path
|
||||
)
|
||||
git_sources: List[_FlatpakSourceType] = [
|
||||
{
|
||||
"type": "shell",
|
||||
"commands": [
|
||||
f'cp -r --reflink=auto "{pkg_repo_dir}" "{CARGO_CRATES}/{name}"'
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": tomlkit.dumps(git_pkg.normalized),
|
||||
"dest": f"{CARGO_CRATES}/{name}", # -{version}',
|
||||
"dest-filename": "Cargo.toml",
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": json.dumps({"package": None, "files": {}}),
|
||||
"dest": f"{CARGO_CRATES}/{name}", # -{version}',
|
||||
"dest-filename": ".cargo-checksum.json",
|
||||
},
|
||||
]
|
||||
|
||||
return (git_sources, cargo_vendored_entry)
|
||||
|
||||
|
||||
async def get_package_sources(
|
||||
package: _TomlType,
|
||||
cargo_lock: _TomlType,
|
||||
git_repos: _GitReposType,
|
||||
) -> Optional[Tuple[List[_FlatpakSourceType], _VendorEntryType]]:
|
||||
metadata = cargo_lock.get("metadata")
|
||||
name = package["name"]
|
||||
version = package["version"]
|
||||
|
||||
if "source" not in package:
|
||||
logging.debug("%s has no source", name)
|
||||
return None
|
||||
source = package["source"]
|
||||
|
||||
if source.startswith("git+"):
|
||||
return await get_git_package_sources(package, git_repos)
|
||||
|
||||
key = f"checksum {name} {version} ({source})"
|
||||
if metadata is not None and key in metadata:
|
||||
checksum = metadata[key]
|
||||
elif "checksum" in package:
|
||||
checksum = package["checksum"]
|
||||
else:
|
||||
logging.warning(f"{name} doesn't have checksum")
|
||||
return None
|
||||
crate_sources = [
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": f"{CRATES_IO}/{name}/{name}-{version}.crate",
|
||||
"sha256": checksum,
|
||||
"dest": f"{CARGO_CRATES}/{name}-{version}",
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": json.dumps({"package": checksum, "files": {}}),
|
||||
"dest": f"{CARGO_CRATES}/{name}-{version}",
|
||||
"dest-filename": ".cargo-checksum.json",
|
||||
},
|
||||
]
|
||||
return (crate_sources, {"crates-io": {"replace-with": VENDORED_SOURCES}})
|
||||
|
||||
|
||||
async def generate_sources(
|
||||
cargo_lock: _TomlType,
|
||||
git_tarballs: bool = False,
|
||||
) -> List[_FlatpakSourceType]:
|
||||
git_repos: _GitReposType = {}
|
||||
sources: List[_FlatpakSourceType] = []
|
||||
package_sources = []
|
||||
cargo_vendored_sources = {
|
||||
VENDORED_SOURCES: {"directory": f"{CARGO_CRATES}"},
|
||||
}
|
||||
|
||||
pkg_coros = [
|
||||
get_package_sources(p, cargo_lock, git_repos) for p in cargo_lock["package"]
|
||||
]
|
||||
for pkg in await asyncio.gather(*pkg_coros):
|
||||
if pkg is None:
|
||||
continue
|
||||
else:
|
||||
pkg_sources, cargo_vendored_entry = pkg
|
||||
package_sources.extend(pkg_sources)
|
||||
cargo_vendored_sources.update(cargo_vendored_entry)
|
||||
|
||||
logging.debug(
|
||||
"Adding collected git repos:\n%s", json.dumps(list(git_repos), indent=4)
|
||||
)
|
||||
git_repo_coros = []
|
||||
for git_url, git_repo in git_repos.items():
|
||||
for git_commit in git_repo["commits"]:
|
||||
git_repo_coros.append(
|
||||
get_git_repo_sources(git_url, git_commit, git_tarballs)
|
||||
)
|
||||
sources.extend(sum(await asyncio.gather(*git_repo_coros), []))
|
||||
|
||||
sources.extend(package_sources)
|
||||
|
||||
logging.debug("Vendored sources:\n%s", json.dumps(cargo_vendored_sources, indent=4))
|
||||
sources.append(
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": tomlkit.dumps(
|
||||
{
|
||||
"source": cargo_vendored_sources,
|
||||
}
|
||||
),
|
||||
"dest": CARGO_HOME,
|
||||
"dest-filename": "config",
|
||||
}
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("cargo_lock", help="Path to the Cargo.lock file")
|
||||
parser.add_argument(
|
||||
"-o", "--output", required=False, help="Where to write generated sources"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yaml", action="store_true", help="Output as YAML instead of JSON"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--git-tarballs",
|
||||
action="store_true",
|
||||
help="Download git repos as tarballs",
|
||||
)
|
||||
parser.add_argument("-d", "--debug", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output is not None:
|
||||
outfile = args.output
|
||||
elif args.yaml and YAML_AVAIL:
|
||||
outfile = "generated-sources.yml"
|
||||
else:
|
||||
outfile = "generated-sources.json"
|
||||
if args.debug:
|
||||
loglevel = logging.DEBUG
|
||||
else:
|
||||
loglevel = logging.INFO
|
||||
logging.basicConfig(level=loglevel)
|
||||
|
||||
generated_sources = asyncio.run(
|
||||
generate_sources(load_toml(args.cargo_lock), git_tarballs=args.git_tarballs)
|
||||
)
|
||||
|
||||
if args.yaml and YAML_AVAIL:
|
||||
with open(outfile, "w", encoding="utf-8") as out:
|
||||
yaml.dump(generated_sources, out, sort_keys=False)
|
||||
else:
|
||||
with open(outfile, "w", encoding="utf-8") as out:
|
||||
json.dump(generated_sources, out, indent=4, sort_keys=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -285,6 +285,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -511,13 +517,34 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -528,7 +555,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1772,11 +1799,14 @@ dependencies = [
|
||||
name = "moku"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"nix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1836,6 +1866,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -2591,6 +2633,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3268,7 +3321,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3318,7 +3371,7 @@ checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -3798,7 +3851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -4410,6 +4463,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4452,6 +4514,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -4509,6 +4586,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4527,6 +4610,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4545,6 +4634,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4575,6 +4670,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4593,6 +4694,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4611,6 +4718,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4629,6 +4742,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4773,7 +4892,7 @@ dependencies = [
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
|
||||
@@ -19,6 +19,9 @@ tauri = { version = "2.0", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
walkdir = "2"
|
||||
nix = { version = "0.29", features = ["fs"] }
|
||||
dirs = "5"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 803 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -1,14 +1,77 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use nix::sys::statvfs::statvfs;
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_shell::{ShellExt, process::CommandChild};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
struct ServerState(Mutex<Option<CommandChild>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageInfo {
|
||||
manga_bytes: u64,
|
||||
total_bytes: u64,
|
||||
free_bytes: u64,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
|
||||
if !downloads_path.trim().is_empty() {
|
||||
return PathBuf::from(downloads_path);
|
||||
}
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/"))
|
||||
.join(".local/share")
|
||||
});
|
||||
base.join("Tachidesk/downloads")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
|
||||
let path = resolve_downloads_path(&downloads_path);
|
||||
|
||||
let manga_bytes = if path.exists() {
|
||||
WalkDir::new(&path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let stat_path = if path.exists() { path.clone() } else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// f_frsize is the fundamental block size used for block counts.
|
||||
// f_bsize (block_size()) is just the preferred I/O size and must not be
|
||||
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
|
||||
let frsize = vfs.fragment_size() as u64;
|
||||
let total_bytes = vfs.blocks() * frsize;
|
||||
let free_bytes = vfs.blocks_available() * frsize;
|
||||
|
||||
Ok(StorageInfo {
|
||||
manga_bytes,
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(ServerState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![get_storage_info])
|
||||
.setup(|app| {
|
||||
let shell = app.shell();
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import {
|
||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||
Square, Rows, Download, ArrowsLeftRight,
|
||||
@@ -13,8 +13,53 @@ import { useStore, type FitMode } from "../../store";
|
||||
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
|
||||
import s from "./Reader.module.css";
|
||||
|
||||
// ── LRU image cache ───────────────────────────────────────────────────────────
|
||||
// Keeps browser memory in check by revoking object-URLs for chapters that
|
||||
// have scrolled far away. We cache by chapterId (not URL) so that we can
|
||||
// drop a whole chapter at once.
|
||||
const MAX_CACHED_CHAPTERS = 6;
|
||||
|
||||
// Track insertion order so we can evict the oldest chapter.
|
||||
const chapterCacheOrder: number[] = [];
|
||||
|
||||
function touchChapterOrder(chapterId: number) {
|
||||
const idx = chapterCacheOrder.indexOf(chapterId);
|
||||
if (idx !== -1) chapterCacheOrder.splice(idx, 1);
|
||||
chapterCacheOrder.push(chapterId);
|
||||
}
|
||||
|
||||
function evictOldestChapter(
|
||||
pageCache: React.MutableRefObject<Map<number, string[]>>,
|
||||
keepIds: Set<number>,
|
||||
): number | null {
|
||||
for (let i = 0; i < chapterCacheOrder.length; i++) {
|
||||
const id = chapterCacheOrder[i];
|
||||
if (!keepIds.has(id)) {
|
||||
chapterCacheOrder.splice(i, 1);
|
||||
pageCache.current.delete(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fire-and-forget: create an Image and let the browser cache it. */
|
||||
function preloadImage(url: string) {
|
||||
const img = new Image(); img.src = url;
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a single image fully before resolving.
|
||||
* Used to avoid showing a half-painted page.
|
||||
*/
|
||||
function decodeImage(url: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
||||
img.onerror = () => resolve(); // don't block on error
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
@@ -146,9 +191,15 @@ export default function Reader() {
|
||||
const uiRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track which chapters are being fetched so we don't double-fire
|
||||
const fetchingRef = useRef<Set<number>>(new Set());
|
||||
const fetchingRef = useRef<Set<number>>(new Set());
|
||||
// Whether we've already appended the next chapter into the strip
|
||||
const appendedRef = useRef<Set<number>>(new Set());
|
||||
const appendedRef = useRef<Set<number>>(new Set());
|
||||
// The chapter id whose pages are currently being loaded (prevents stale sets)
|
||||
const loadingChapterRef = useRef<number | null>(null);
|
||||
// Mirror of stripChapters in a ref so the scroll handler never closes over stale state
|
||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||
// Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position
|
||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -157,6 +208,9 @@ export default function Reader() {
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||
// True only after the first page of the new chapter has been decoded,
|
||||
// preventing any flash of the previous chapter's image.
|
||||
const [pageReady, setPageReady] = useState(false);
|
||||
|
||||
/**
|
||||
* The infinite strip: an ordered list of chapter chunks.
|
||||
@@ -170,6 +224,24 @@ export default function Reader() {
|
||||
*/
|
||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||
|
||||
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
||||
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
||||
|
||||
// Restore scroll position synchronously after a head-trim, before the browser paints
|
||||
useLayoutEffect(() => {
|
||||
const anchor = scrollAnchorRef.current;
|
||||
if (!anchor || !containerRef.current) return;
|
||||
scrollAnchorRef.current = null;
|
||||
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
|
||||
// gained is negative when we removed nodes (scrollHeight shrank)
|
||||
// We want scrollTop to decrease by the same amount so the visible content stays put.
|
||||
// But since we removed nodes from the top, scrollHeight already shrank —
|
||||
// we just need to subtract the removed pixel height from scrollTop.
|
||||
if (gained < 0) {
|
||||
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
|
||||
}
|
||||
}, [stripChapters]);
|
||||
|
||||
const {
|
||||
activeManga, activeChapter, activeChapterList,
|
||||
pageUrls, pageNumber, settings,
|
||||
@@ -212,7 +284,10 @@ export default function Reader() {
|
||||
// ── Fetch helpers ────────────────────────────────────────────────────────────
|
||||
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
|
||||
const cached = pageCache.current.get(chapterId);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
touchChapterOrder(chapterId);
|
||||
return cached;
|
||||
}
|
||||
if (fetchingRef.current.has(chapterId)) {
|
||||
// Poll until another in-flight fetch resolves
|
||||
return new Promise((resolve) => {
|
||||
@@ -228,6 +303,12 @@ export default function Reader() {
|
||||
);
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(chapterId, urls);
|
||||
touchChapterOrder(chapterId);
|
||||
// Evict oldest chapters if we're over the limit, but always keep the
|
||||
// immediately adjacent chapters so navigation is instant.
|
||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
||||
evictOldestChapter(pageCache, new Set([chapterId]));
|
||||
}
|
||||
fetchingRef.current.delete(chapterId);
|
||||
return urls;
|
||||
}, []);
|
||||
@@ -235,13 +316,25 @@ export default function Reader() {
|
||||
// ── Load pages ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter) return;
|
||||
setLoading(true); setError(null); setPageGroups([]);
|
||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||
appendedRef.current = new Set();
|
||||
|
||||
fetchPages(activeChapter.id)
|
||||
.then((urls) => {
|
||||
const targetId = activeChapter.id;
|
||||
loadingChapterRef.current = targetId;
|
||||
|
||||
fetchPages(targetId)
|
||||
.then(async (urls) => {
|
||||
// Discard result if the user has already navigated to a different chapter
|
||||
if (loadingChapterRef.current !== targetId) return;
|
||||
|
||||
// Decode the first page before committing so no previous chapter flashes
|
||||
await decodeImage(urls[0]);
|
||||
|
||||
if (loadingChapterRef.current !== targetId) return;
|
||||
|
||||
setPageUrls(urls);
|
||||
setPageReady(true);
|
||||
if (style === "longstrip" && autoNext) {
|
||||
setStripChapters([{
|
||||
chapterId: activeChapter.id,
|
||||
@@ -256,7 +349,9 @@ export default function Reader() {
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
if (loadingChapterRef.current === targetId) setLoading(false);
|
||||
});
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
||||
@@ -303,11 +398,16 @@ export default function Reader() {
|
||||
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
||||
|
||||
// ── Preload ─────────────────────────────────────────────────────────────────
|
||||
// Eagerly decode pages ahead; fire-and-forget preload for pages behind.
|
||||
useEffect(() => {
|
||||
for (let i = 1; i <= (settings.preloadPages ?? 3); i++) {
|
||||
const ahead = settings.preloadPages ?? 3;
|
||||
for (let i = 1; i <= ahead; i++) {
|
||||
const url = pageUrls[pageNumber - 1 + i];
|
||||
if (url) preloadImage(url);
|
||||
if (url) decodeImage(url); // uses browser cache — no duplicate network request
|
||||
}
|
||||
// Also keep one page behind warm
|
||||
const behindUrl = pageUrls[pageNumber - 2];
|
||||
if (behindUrl) preloadImage(behindUrl);
|
||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
||||
|
||||
// ── Adjacent chapters ────────────────────────────────────────────────────────
|
||||
@@ -323,6 +423,11 @@ export default function Reader() {
|
||||
}, [activeChapter, activeChapterList]);
|
||||
|
||||
useEffect(() => {
|
||||
const pinned = new Set<number>();
|
||||
if (activeChapter) pinned.add(activeChapter.id);
|
||||
if (adjacent.next) pinned.add(adjacent.next.id);
|
||||
if (adjacent.prev) pinned.add(adjacent.prev.id);
|
||||
|
||||
const preload = (id: number) => {
|
||||
fetchPages(id)
|
||||
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
|
||||
@@ -330,6 +435,13 @@ export default function Reader() {
|
||||
};
|
||||
if (adjacent.next) preload(adjacent.next.id);
|
||||
if (adjacent.prev) preload(adjacent.prev.id);
|
||||
|
||||
// After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS
|
||||
// that isn't pinned as adjacent or current.
|
||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
||||
const evicted = evictOldestChapter(pageCache, pinned);
|
||||
if (evicted === null) break; // nothing left to evict
|
||||
}
|
||||
}, [adjacent.next?.id, adjacent.prev?.id]);
|
||||
|
||||
const lastPage = pageUrls.length;
|
||||
@@ -394,20 +506,33 @@ export default function Reader() {
|
||||
const goForward = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||
if (pageNumber < lastPage) {
|
||||
setPageNumber(pageNumber + 1);
|
||||
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
||||
if (nextUrl) {
|
||||
decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1));
|
||||
} else {
|
||||
setPageNumber(pageNumber + 1);
|
||||
}
|
||||
} else if (adjacent.next) {
|
||||
setPageNumber(1);
|
||||
openReader(adjacent.next, activeChapterList);
|
||||
} else {
|
||||
closeReader();
|
||||
}
|
||||
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
if (pageNumber > 1) setPageNumber(pageNumber - 1);
|
||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
}, [pageNumber, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
if (pageNumber > 1) {
|
||||
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
||||
if (prevUrl) {
|
||||
decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1));
|
||||
} else {
|
||||
setPageNumber(pageNumber - 1);
|
||||
}
|
||||
} else if (adjacent.prev) {
|
||||
openReader(adjacent.prev, activeChapterList);
|
||||
}
|
||||
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goNext = rtl ? goBack : goForward;
|
||||
const goPrev = rtl ? goForward : goBack;
|
||||
@@ -494,24 +619,27 @@ export default function Reader() {
|
||||
|
||||
// ── Infinite append ──────────────────────────────────────────────────
|
||||
if (!autoNext) {
|
||||
// Classic behavior: jump to next chapter at the very end of scroll
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||
return;
|
||||
}
|
||||
|
||||
const strip = stripChaptersRef.current;
|
||||
|
||||
// Silently update visibleChapterId as we scroll into each chunk
|
||||
for (const chunk of stripChapters) {
|
||||
for (const chunk of strip) {
|
||||
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
||||
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
||||
if (chunk.chapterId !== visibleChapterId) {
|
||||
setVisibleChapterId(chunk.chapterId);
|
||||
// Mark as read when we scroll into a new chapter
|
||||
if (!markedRead.has(chunk.chapterId) && settings.autoMarkRead) {
|
||||
const prevChunk = stripChapters[stripChapters.indexOf(chunk) - 1];
|
||||
if (settings.autoMarkRead) {
|
||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
||||
if (prevChunk) {
|
||||
setMarkedRead((r) => new Set(r).add(prevChunk.chapterId));
|
||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||
setMarkedRead((r) => {
|
||||
if (r.has(prevChunk.chapterId)) return r;
|
||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||
return new Set(r).add(prevChunk.chapterId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,12 +647,11 @@ export default function Reader() {
|
||||
}
|
||||
}
|
||||
|
||||
// Append next chapter 300px before we hit the bottom of the last chunk
|
||||
// Append next chapter when within 300px of the bottom
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
||||
if (!nearBottom) return;
|
||||
|
||||
// What's the last chapter currently in the strip?
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
const lastChunk = strip[strip.length - 1];
|
||||
if (!lastChunk) return;
|
||||
|
||||
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
|
||||
@@ -533,20 +660,26 @@ export default function Reader() {
|
||||
const nextChEntry = activeChapterList[lastChunkIdx + 1];
|
||||
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
|
||||
|
||||
// Mark immediately so concurrent scroll events don't double-append
|
||||
appendedRef.current.add(nextChEntry.id);
|
||||
|
||||
// Fetch (likely already cached from preload) then append to strip
|
||||
fetchPages(nextChEntry.id).then((urls) => {
|
||||
setStripChapters((prev) => {
|
||||
const lastInPrev = prev[prev.length - 1];
|
||||
const newStart = lastInPrev
|
||||
? lastInPrev.startGlobalIdx + lastInPrev.urls.length
|
||||
: 0;
|
||||
return [
|
||||
const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0;
|
||||
const next = [
|
||||
...prev,
|
||||
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
|
||||
];
|
||||
|
||||
const MAX_STRIP_CHAPTERS = 3;
|
||||
if (next.length > MAX_STRIP_CHAPTERS) {
|
||||
const toRemove = next.length - MAX_STRIP_CHAPTERS;
|
||||
// Snapshot scroll position now, inside the state updater, before React
|
||||
// removes the nodes. useLayoutEffect will restore it after the DOM mutation.
|
||||
scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight };
|
||||
return next.slice(toRemove);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
@@ -557,7 +690,7 @@ export default function Reader() {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [style, autoNext, stripChapters, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
|
||||
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
|
||||
|
||||
// Reset scroll position when switching chapters in non-longstrip modes
|
||||
useEffect(() => {
|
||||
@@ -781,13 +914,15 @@ export default function Reader() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
key={pageNumber}
|
||||
src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`}
|
||||
className={imgCls}
|
||||
decoding="async"
|
||||
/>
|
||||
pageReady && (
|
||||
<img
|
||||
key={pageNumber}
|
||||
src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`}
|
||||
className={imgCls}
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -279,6 +279,84 @@
|
||||
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.kbReset:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
/* ─── Storage ── */
|
||||
.storageLoading {
|
||||
font-size: var(--text-sm); color: var(--text-faint);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
}
|
||||
|
||||
.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); }
|
||||
|
||||
.storageBar {
|
||||
width: 100%; height: 7px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storageBarFill {
|
||||
height: 100%; border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.storageBarWarn { background: #d97706; }
|
||||
.storageBarCritical { background: var(--color-error); }
|
||||
|
||||
.storageBarLabels {
|
||||
display: flex; justify-content: space-between;
|
||||
margin-top: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.storageBarUsed { color: var(--text-secondary); }
|
||||
.storageBarFree { color: var(--text-faint); }
|
||||
|
||||
.storageBarNote {
|
||||
font-size: var(--text-xs); color: var(--text-faint);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.storageLegend {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.storageLegendRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.storageDot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.storageDotManga { background: var(--accent); }
|
||||
.storageDotApp { background: var(--border-strong); }
|
||||
.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
||||
|
||||
.storageLegendLabel { flex: 1; color: var(--text-muted); }
|
||||
.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.storageLimitHint {
|
||||
font-size: var(--text-xs); color: #d97706;
|
||||
padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
|
||||
.setLimitBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; border: 1px solid var(--border-strong);
|
||||
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); }
|
||||
|
||||
.storagePathNote {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) var(--sp-3) var(--sp-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ─── About ── */
|
||||
.aboutBlock {
|
||||
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear } from "@phosphor-icons/react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives } from "@phosphor-icons/react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
import s from "./Settings.module.css";
|
||||
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "about";
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "about";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||
@@ -13,6 +16,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
|
||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
];
|
||||
|
||||
@@ -397,6 +401,172 @@ function KeybindsTab({ settings, update, reset }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Storage helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
interface StorageInfo {
|
||||
manga_bytes: number;
|
||||
total_bytes: number;
|
||||
free_bytes: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function StorageBar({ used, limit, total }: { used: number; limit: number | null; total: number }) {
|
||||
const cap = limit ?? total;
|
||||
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
||||
const critical = pctUsed > 90;
|
||||
const warning = pctUsed > 75;
|
||||
|
||||
return (
|
||||
<div className={s.storageBarWrap}>
|
||||
<div className={s.storageBar}>
|
||||
<div
|
||||
className={[s.storageBarFill, critical ? s.storageBarCritical : warning ? s.storageBarWarn : ""].join(" ")}
|
||||
style={{ width: `${pctUsed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.storageBarLabels}>
|
||||
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
|
||||
<span className={s.storageBarFree}>{fmtBytes(Math.max(0, cap - used))} free</span>
|
||||
</div>
|
||||
{limit !== null && total > 0 && (
|
||||
<p className={s.storageBarNote}>Limit {fmtBytes(limit)} of {fmtBytes(total)} total</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
const [info, setInfo] = useState<StorageInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
|
||||
const limitGb = settings.storageLimitGb ?? null;
|
||||
const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null;
|
||||
|
||||
async function fetchInfo() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
|
||||
const result = await invoke<StorageInfo>("get_storage_info", {
|
||||
downloadsPath: pathData.settings.downloadsPath,
|
||||
});
|
||||
setInfo(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchInfo(); }, []);
|
||||
|
||||
function handleClearCache() {
|
||||
setClearing(true);
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.map((n) => caches.delete(n))))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setClearing(false);
|
||||
setCleared(true);
|
||||
setTimeout(() => setCleared(false), 2500);
|
||||
fetchInfo();
|
||||
});
|
||||
}
|
||||
|
||||
const mangaBytes = info?.manga_bytes ?? 0;
|
||||
const totalBytes = info?.total_bytes ?? 0;
|
||||
const freeBytes = info?.free_bytes ?? 0;
|
||||
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Disk Usage</p>
|
||||
{loading && <p className={s.storageLoading}>Reading filesystem…</p>}
|
||||
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
|
||||
{!loading && !error && info && (
|
||||
<>
|
||||
<StorageBar used={mangaBytes} limit={limitBytes} total={totalBytes} />
|
||||
<div className={s.storageLegend}>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Downloaded manga</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(mangaBytes)}</span>
|
||||
</div>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotFree].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Drive free</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(freeBytes)}</span>
|
||||
</div>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotApp].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Drive total</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(totalBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.storagePathNote}>{info.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Storage Limit</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Limit download storage</span>
|
||||
<span className={s.toggleDesc}>
|
||||
{limitGb === null
|
||||
? "No limit — uses full drive capacity"
|
||||
: `Warn when downloads exceed ${limitGb} GB`}
|
||||
</span>
|
||||
</div>
|
||||
{limitGb === null ? (
|
||||
<button className={s.setLimitBtn} onClick={() => update({ storageLimitGb: 10 })}>
|
||||
Set limit
|
||||
</button>
|
||||
) : (
|
||||
<div className={s.stepControls}>
|
||||
<button className={s.stepBtn}
|
||||
onClick={() => update({ storageLimitGb: Math.max(1, limitGb - 1) })}
|
||||
disabled={limitGb <= 1}>−</button>
|
||||
<span className={s.stepVal}>{limitGb} GB</span>
|
||||
<button className={s.stepBtn}
|
||||
onClick={() => update({ storageLimitGb: limitGb + 1 })}>+</button>
|
||||
<button className={s.kbReset} onClick={() => update({ storageLimitGb: null })} title="Remove limit">↺</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (
|
||||
<p className={s.storageLimitHint}>Limit exceeds available free space ({fmtBytes(freeBytes)})</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Cache</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Image cache</span>
|
||||
<span className={s.toggleDesc}>Cached page images stored by the webview</span>
|
||||
</div>
|
||||
<button className={s.dangerBtn} onClick={handleClearCache} disabled={clearing}>
|
||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AboutTab() {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
@@ -467,6 +637,7 @@ export default function SettingsModal() {
|
||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
|
||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +163,24 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||
query GetDownloadedChaptersPages {
|
||||
chapters(condition: { isDownloaded: true }) {
|
||||
nodes {
|
||||
pageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADS_PATH = `
|
||||
query GetDownloadsPath {
|
||||
settings {
|
||||
downloadsPath
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Downloads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Settings {
|
||||
autoStartServer: boolean;
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
storageLimitGb: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -76,6 +77,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
autoStartServer: true,
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
storageLimitGb: null,
|
||||
};
|
||||
|
||||
interface Store {
|
||||
|
||||