[V1] Flatpak Release + Buffering Fix & Storage Management

This commit is contained in:
Youwes09
2026-02-22 13:37:02 -06:00
parent 7ab3cf3df3
commit d834e10fd8
21 changed files with 7591 additions and 49 deletions
+6
View File
@@ -33,3 +33,9 @@ yarn-error.log*
# --- Tauri specific ---
src-tauri/target/
src-tauri/gen/
# --- Flatpak build artifacts ---
build-dir/
repo/
*.flatpak
.flatpak-builder/
+90
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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
+36
View File
@@ -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>
+511
View File
@@ -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()
Binary file not shown.
+125 -6
View File
@@ -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",
+3
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

+63
View File
@@ -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();
+175 -40
View File
@@ -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);
+173 -2
View File
@@ -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}>&#8722;</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>
+18
View File
@@ -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 = `
+2
View File
@@ -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 {