Initial Comicviewer implementation
This commit is contained in:
commit
cb01395612
20 changed files with 4940 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/target/
|
||||||
|
/AppDir/
|
||||||
|
/.appimage-tools/
|
||||||
|
*.AppImage
|
||||||
|
*.AppImage.zsync
|
||||||
86
AGENTS.md
Normal file
86
AGENTS.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux.
|
||||||
|
It browses local, SMB, FTP, and SFTP locations and opens image files stored in
|
||||||
|
ZIP archives.
|
||||||
|
|
||||||
|
## Technology
|
||||||
|
|
||||||
|
- Rust
|
||||||
|
- GTK4 with GIO/GVfs for desktop and remote filesystem integration
|
||||||
|
- SQLite for cached directory metadata, search indexes, and reading history
|
||||||
|
- AppImage as the primary distribution format
|
||||||
|
- Korean-first UI with all user-facing strings kept translatable
|
||||||
|
|
||||||
|
## Core Requirements
|
||||||
|
|
||||||
|
- Save local and remote location profiles, including credentials.
|
||||||
|
- Store credentials as plain text in the application configuration as requested,
|
||||||
|
but restrict the configuration file permissions to `0600`.
|
||||||
|
- Cache remote directory metadata and refresh it asynchronously when browsing.
|
||||||
|
- Download remote ZIP archives before opening them.
|
||||||
|
- Limit the persistent ZIP cache with a user-configurable LRU quota. The default
|
||||||
|
is 10 GB. A quota of 0 disables persistent ZIP caching and uses temporary files.
|
||||||
|
- Support JPEG, PNG, WebP, static GIF, BMP, and AVIF images in ZIP archives.
|
||||||
|
- Sort archive images and browser entries using natural filename ordering.
|
||||||
|
- Support ascending and descending sorting by name, modification time, and
|
||||||
|
creation time. Treat unavailable creation times as unknown rather than
|
||||||
|
inventing values.
|
||||||
|
- Search folder and ZIP names recursively below the current location using the
|
||||||
|
cached index.
|
||||||
|
- Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing.
|
||||||
|
- Support both right-to-left and left-to-right two-page layouts, plus controls
|
||||||
|
that advance by one page to adjust page pairing.
|
||||||
|
- Save progress per ZIP and save the most recently read ZIP for resume support.
|
||||||
|
|
||||||
|
## Data Locations
|
||||||
|
|
||||||
|
Follow the XDG Base Directory specification:
|
||||||
|
|
||||||
|
- Configuration: `${XDG_CONFIG_HOME:-~/.config}/comicviewer/`
|
||||||
|
- Persistent data: `${XDG_DATA_HOME:-~/.local/share}/comicviewer/`
|
||||||
|
- Cache: `${XDG_CACHE_HOME:-~/.cache}/comicviewer/`
|
||||||
|
|
||||||
|
Do not write application state into the source tree.
|
||||||
|
|
||||||
|
## Engineering Guidelines
|
||||||
|
|
||||||
|
- Prefer the smallest correct implementation and avoid speculative abstractions.
|
||||||
|
- Keep network and archive work off the GTK main thread.
|
||||||
|
- Show cached directory data immediately, then update it in the background.
|
||||||
|
- Use atomic writes for configuration, database migrations, and completed remote
|
||||||
|
downloads. Incomplete downloads must use a distinct temporary suffix.
|
||||||
|
- Do not extract an entire archive to disk. Decode only requested images and
|
||||||
|
prefetch a small window around the current page.
|
||||||
|
- Validate archive entry sizes and image dimensions before allocation.
|
||||||
|
- Never trust archive paths or use them as extraction destinations.
|
||||||
|
- Preserve errors from GIO, ZIP parsing, and image decoding and present concise,
|
||||||
|
actionable messages in the UI.
|
||||||
|
- Keep protocol-independent browsing logic separate from GTK widgets.
|
||||||
|
- Do not introduce credential encryption or keyring integration unless the
|
||||||
|
requirement changes.
|
||||||
|
|
||||||
|
## Quality Checks
|
||||||
|
|
||||||
|
Before considering a change complete, run the checks available in the project,
|
||||||
|
including formatting, linting, and tests. Once the Rust project exists, the
|
||||||
|
minimum expected commands are:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
cargo test --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
Add focused tests for natural sorting, cache eviction, metadata refresh,
|
||||||
|
archive filtering, reading progress, and remote file change detection.
|
||||||
|
|
||||||
|
## Git Practices
|
||||||
|
|
||||||
|
- Keep commits focused and do not commit generated build output or cached data.
|
||||||
|
- Do not commit real server addresses, usernames, passwords, or local test
|
||||||
|
configuration.
|
||||||
|
- Update documentation when runtime dependencies or AppImage requirements
|
||||||
|
change.
|
||||||
1543
Cargo.lock
generated
Normal file
1543
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "comicviewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "A comic archive viewer for local and remote locations"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] }
|
||||||
|
filetime = "0.2"
|
||||||
|
percent-encoding = "2.3"
|
||||||
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
tempfile = "3.20"
|
||||||
|
toml = "0.9"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
strip = true
|
||||||
45
README.md
Normal file
45
README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Comicviewer
|
||||||
|
|
||||||
|
Comicviewer is a GTK4 desktop application for browsing and reading comic image
|
||||||
|
archives stored locally or on SMB, FTP, and SFTP servers.
|
||||||
|
|
||||||
|
The project is currently in early development. It supports saved local, SMB,
|
||||||
|
FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache,
|
||||||
|
natural sorting, safe on-demand image decoding and prefetching, single-page and
|
||||||
|
two-page viewing, continuous vertical scrolling, reading progress, and
|
||||||
|
XDG-compliant configuration.
|
||||||
|
|
||||||
|
## Development dependencies
|
||||||
|
|
||||||
|
On CachyOS or Arch Linux:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -S rust gtk4 sqlite gvfs gvfs-smb
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the application with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
GVfs provides the FTP and SFTP backends. `gvfs-smb` adds SMB support. These
|
||||||
|
services must be installed on the host even when Comicviewer is distributed as
|
||||||
|
an AppImage.
|
||||||
|
|
||||||
|
Application files are stored below the standard XDG configuration, data, and
|
||||||
|
cache directories. No runtime state is written into the source tree.
|
||||||
|
|
||||||
|
## AppImage
|
||||||
|
|
||||||
|
The AppImage build uses `linuxdeploy` and `linuxdeploy-plugin-gtk`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PATH="/path/to/appimage-tools:$PATH" \
|
||||||
|
LINUXDEPLOY="/path/to/linuxdeploy-x86_64.AppImage" \
|
||||||
|
./packaging/build-appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting file is written to `dist/`. Remote locations still require the
|
||||||
|
host system's GVfs services and protocol backends, including `gvfs-smb` for
|
||||||
|
SMB.
|
||||||
12
data/io.github.comicviewer.Comicviewer.desktop
Normal file
12
data/io.github.comicviewer.Comicviewer.desktop
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Comicviewer
|
||||||
|
Name[ko]=코믹뷰어
|
||||||
|
Comment=Read comic images stored in ZIP archives
|
||||||
|
Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다
|
||||||
|
Exec=comicviewer
|
||||||
|
Icon=io.github.comicviewer.Comicviewer
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Viewer;
|
||||||
|
MimeType=application/zip;application/vnd.comicbook+zip;
|
||||||
|
StartupNotify=true
|
||||||
31
data/io.github.comicviewer.Comicviewer.metainfo.xml
Normal file
31
data/io.github.comicviewer.Comicviewer.metainfo.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.comicviewer.Comicviewer</id>
|
||||||
|
<name>Comicviewer</name>
|
||||||
|
<summary>Read local and remote comic ZIP archives</summary>
|
||||||
|
<summary xml:lang="ko">로컬 및 원격 만화 ZIP 파일 보기</summary>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
|
<developer id="io.github.comicviewer">
|
||||||
|
<name>Comicviewer contributors</name>
|
||||||
|
</developer>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Comicviewer browses local, SMB, FTP, and SFTP locations and displays
|
||||||
|
images from ZIP archives without extracting the complete archive.
|
||||||
|
</p>
|
||||||
|
<p xml:lang="ko">
|
||||||
|
코믹뷰어는 로컬, SMB, FTP, SFTP 위치를 탐색하고 ZIP 전체를 풀지
|
||||||
|
않고 압축된 이미지를 표시합니다.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">io.github.comicviewer.Comicviewer.desktop</launchable>
|
||||||
|
<url type="homepage">https://github.com/burningtuna</url>
|
||||||
|
<provides>
|
||||||
|
<binary>comicviewer</binary>
|
||||||
|
</provides>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<releases>
|
||||||
|
<release version="0.1.0" date="2026-07-21" />
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
14
data/io.github.comicviewer.Comicviewer.svg
Normal file
14
data/io.github.comicviewer.Comicviewer.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cover" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff6b4a"/>
|
||||||
|
<stop offset="1" stop-color="#c82f63"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="256" height="256" rx="56" fill="#20232b"/>
|
||||||
|
<path d="M46 48h74c18 0 32 8 40 20 8-12 22-20 40-20h10v154h-18c-15 0-27 5-32 14-5-9-17-14-32-14H46z" fill="url(#cover)"/>
|
||||||
|
<path d="M57 61h61c17 0 29 7 35 19v113c-9-6-20-9-34-9H57z" fill="#fff5e9"/>
|
||||||
|
<path d="M199 61h-8c-14 0-25 4-33 13v119c9-6 20-9 34-9h7z" fill="#ffe3cd"/>
|
||||||
|
<path d="M72 84h64v12H72zm0 25h64v12H72zm0 25h49v12H72z" fill="#30343d" opacity=".8"/>
|
||||||
|
<path d="M174 91l14 14-14 14m14-14h-24" fill="none" stroke="#c82f63" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 827 B |
7
packaging/AppRun
Executable file
7
packaging/AppRun
Executable file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
app_dir="$(dirname "$(readlink -f "$0")")"
|
||||||
|
export XDG_DATA_DIRS="${app_dir}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||||
|
|
||||||
|
exec "${app_dir}/usr/bin/comicviewer" "$@"
|
||||||
47
packaging/build-appimage.sh
Executable file
47
packaging/build-appimage.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
linuxdeploy="${LINUXDEPLOY:-linuxdeploy-x86_64.AppImage}"
|
||||||
|
output_dir="${OUTPUT_DIR:-${root_dir}/dist}"
|
||||||
|
work_dir="$(mktemp -d)"
|
||||||
|
app_dir="${work_dir}/Comicviewer.AppDir"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${work_dir}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cargo build --manifest-path "${root_dir}/Cargo.toml" --release
|
||||||
|
mkdir -p "${output_dir}"
|
||||||
|
mkdir -p "${app_dir}/usr/share/metainfo"
|
||||||
|
install -m 0644 \
|
||||||
|
"${root_dir}/data/io.github.comicviewer.Comicviewer.metainfo.xml" \
|
||||||
|
"${app_dir}/usr/share/metainfo/io.github.comicviewer.Comicviewer.appdata.xml"
|
||||||
|
|
||||||
|
output_path="${output_dir}/Comicviewer-0.1.0-x86_64.AppImage"
|
||||||
|
export LDAI_OUTPUT="${output_path}"
|
||||||
|
export PATH="$(dirname "${linuxdeploy}"):${PATH}"
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
export NO_STRIP=1
|
||||||
|
|
||||||
|
# The GTK plugin assumes this optional module directory exists on every distro.
|
||||||
|
gtk_plugin="$(command -v linuxdeploy-plugin-gtk.sh || true)"
|
||||||
|
if [[ -n "${gtk_plugin}" ]] && [[ ! -d "$(pkg-config --variable=libdir gtk4)/gtk-4.0" ]]; then
|
||||||
|
patched_plugin="${work_dir}/linuxdeploy-plugin-gtk.sh"
|
||||||
|
cp "${gtk_plugin}" "${patched_plugin}"
|
||||||
|
sed -i 's| copy_lib_tree "$gtk4_libdir" "$APPDIR/"| if [ -d "$gtk4_libdir" ]; then copy_lib_tree "$gtk4_libdir" "$APPDIR/"; fi|' "${patched_plugin}"
|
||||||
|
chmod +x "${patched_plugin}"
|
||||||
|
export PATH="${work_dir}:${PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${linuxdeploy}" \
|
||||||
|
--appdir "${app_dir}" \
|
||||||
|
--executable "${root_dir}/target/release/comicviewer" \
|
||||||
|
--desktop-file "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \
|
||||||
|
--icon-file "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \
|
||||||
|
--custom-apprun "${root_dir}/packaging/AppRun" \
|
||||||
|
--plugin gtk \
|
||||||
|
--output appimage
|
||||||
|
|
||||||
|
echo "Created ${output_path}"
|
||||||
178
src/archive.rs
Normal file
178
src/archive.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use image::ImageReader;
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
use crate::browser::natural_cmp;
|
||||||
|
|
||||||
|
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
|
||||||
|
const MAX_IMAGE_DIMENSION: u32 = 30_000;
|
||||||
|
const MAX_IMAGE_PIXELS: u64 = 100_000_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArchivePage {
|
||||||
|
pub index: usize,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DecodedPage {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> {
|
||||||
|
let file = File::open(path)
|
||||||
|
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
||||||
|
let mut archive = ZipArchive::new(file)
|
||||||
|
.with_context(|| format!("올바른 ZIP 파일이 아닙니다: {}", path.display()))?;
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
|
||||||
|
for index in 0..archive.len() {
|
||||||
|
let entry = archive.by_index(index)?;
|
||||||
|
if entry.is_file() && is_supported_image_name(entry.name()) {
|
||||||
|
pages.push(ArchivePage {
|
||||||
|
index,
|
||||||
|
name: entry.name().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.sort_by(|left, right| natural_cmp(&left.name, &right.name));
|
||||||
|
Ok(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_page(path: &Path, page: &ArchivePage) -> Result<DecodedPage> {
|
||||||
|
let file = File::open(path)
|
||||||
|
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
||||||
|
let mut archive = ZipArchive::new(file)?;
|
||||||
|
let entry = archive
|
||||||
|
.by_index(page.index)
|
||||||
|
.with_context(|| format!("ZIP 항목을 찾을 수 없습니다: {}", page.name))?;
|
||||||
|
|
||||||
|
if entry.size() > MAX_ENTRY_SIZE {
|
||||||
|
bail!(
|
||||||
|
"이미지 압축 해제 크기가 제한을 초과합니다: {} (최대 256MB)",
|
||||||
|
page.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let capacity = usize::try_from(entry.size()).context("이미지 크기를 처리할 수 없습니다")?;
|
||||||
|
let mut encoded = Vec::with_capacity(capacity);
|
||||||
|
entry
|
||||||
|
.take(MAX_ENTRY_SIZE + 1)
|
||||||
|
.read_to_end(&mut encoded)
|
||||||
|
.with_context(|| format!("ZIP 이미지를 읽을 수 없습니다: {}", page.name))?;
|
||||||
|
if encoded.len() as u64 > MAX_ENTRY_SIZE {
|
||||||
|
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = ImageReader::new(Cursor::new(&encoded))
|
||||||
|
.with_guessed_format()
|
||||||
|
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
|
||||||
|
let (width, height) = reader
|
||||||
|
.into_dimensions()
|
||||||
|
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
|
||||||
|
validate_dimensions(width, height, &page.name)?;
|
||||||
|
|
||||||
|
let image = image::load_from_memory(&encoded)
|
||||||
|
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?;
|
||||||
|
let rgba = image.into_rgba8();
|
||||||
|
|
||||||
|
Ok(DecodedPage {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgba: rgba.into_raw(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> {
|
||||||
|
let pixels = u64::from(width) * u64::from(height);
|
||||||
|
if width == 0
|
||||||
|
|| height == 0
|
||||||
|
|| width > MAX_IMAGE_DIMENSION
|
||||||
|
|| height > MAX_IMAGE_DIMENSION
|
||||||
|
|| pixels > MAX_IMAGE_PIXELS
|
||||||
|
{
|
||||||
|
bail!("이미지 크기가 안전 제한을 초과합니다: {name} ({width}x{height})");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_image_name(name: &str) -> bool {
|
||||||
|
let extension = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str());
|
||||||
|
extension.is_some_and(|extension| {
|
||||||
|
matches!(
|
||||||
|
extension.to_ascii_lowercase().as_str(),
|
||||||
|
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_supported_image_extensions_case_insensitively() {
|
||||||
|
for name in [
|
||||||
|
"1.JPG", "2.jpeg", "3.png", "4.WebP", "5.gif", "6.bmp", "7.avif",
|
||||||
|
] {
|
||||||
|
assert!(is_supported_image_name(name), "{name}");
|
||||||
|
}
|
||||||
|
assert!(!is_supported_image_name("notes.txt"));
|
||||||
|
assert!(!is_supported_image_name("folder/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_dimensions() {
|
||||||
|
assert!(validate_dimensions(10_000, 10_000, "valid.png").is_ok());
|
||||||
|
assert!(validate_dimensions(10_001, 10_000, "large.png").is_err());
|
||||||
|
assert!(validate_dimensions(30_001, 1, "wide.png").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_and_decodes_images_from_a_zip() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"comicviewer-archive-test-{}.zip",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let mut encoded_png = Cursor::new(Vec::new());
|
||||||
|
let image = image::RgbaImage::from_pixel(2, 3, image::Rgba([10, 20, 30, 255]));
|
||||||
|
image::DynamicImage::ImageRgba8(image)
|
||||||
|
.write_to(&mut encoded_png, image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let file = File::create(&path).unwrap();
|
||||||
|
let mut archive = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default();
|
||||||
|
archive.start_file("page10.png", options).unwrap();
|
||||||
|
archive.write_all(encoded_png.get_ref()).unwrap();
|
||||||
|
archive.start_file("page2.png", options).unwrap();
|
||||||
|
archive.write_all(encoded_png.get_ref()).unwrap();
|
||||||
|
archive.start_file("notes.txt", options).unwrap();
|
||||||
|
archive.write_all(b"ignored").unwrap();
|
||||||
|
archive.finish().unwrap();
|
||||||
|
|
||||||
|
let pages = list_pages(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pages
|
||||||
|
.iter()
|
||||||
|
.map(|page| page.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["page2.png", "page10.png"]
|
||||||
|
);
|
||||||
|
let decoded = decode_page(&path, &pages[0]).unwrap();
|
||||||
|
assert_eq!((decoded.width, decoded.height), (2, 3));
|
||||||
|
assert_eq!(decoded.rgba.len(), 2 * 3 * 4);
|
||||||
|
|
||||||
|
std::fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/browser.rs
Normal file
181
src/browser.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EntryKind {
|
||||||
|
Directory,
|
||||||
|
ZipArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BrowserEntry {
|
||||||
|
pub uri: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: EntryKind,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub modified: Option<SystemTime>,
|
||||||
|
pub created: Option<SystemTime>,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortField {
|
||||||
|
Name,
|
||||||
|
Modified,
|
||||||
|
Created,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SortDirection {
|
||||||
|
Ascending,
|
||||||
|
Descending,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
|
||||||
|
entries.sort_by(|left, right| {
|
||||||
|
let kind_order = match (left.kind, right.kind) {
|
||||||
|
(EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less,
|
||||||
|
(EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater,
|
||||||
|
_ => Ordering::Equal,
|
||||||
|
};
|
||||||
|
if kind_order != Ordering::Equal {
|
||||||
|
return kind_order;
|
||||||
|
}
|
||||||
|
|
||||||
|
match field {
|
||||||
|
SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction),
|
||||||
|
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
|
||||||
|
.then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
|
||||||
|
SortField::Created => compare_optional_time(left.created, right.created, direction)
|
||||||
|
.then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_optional_time(
|
||||||
|
left: Option<SystemTime>,
|
||||||
|
right: Option<SystemTime>,
|
||||||
|
direction: SortDirection,
|
||||||
|
) -> Ordering {
|
||||||
|
match (left, right) {
|
||||||
|
(Some(left), Some(right)) => apply_direction(left.cmp(&right), direction),
|
||||||
|
(Some(_), None) => Ordering::Less,
|
||||||
|
(None, Some(_)) => Ordering::Greater,
|
||||||
|
(None, None) => Ordering::Equal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
|
||||||
|
match direction {
|
||||||
|
SortDirection::Ascending => order,
|
||||||
|
SortDirection::Descending => order.reverse(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_zip_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn natural_cmp(left: &str, right: &str) -> Ordering {
|
||||||
|
let mut left_chars = left.chars().peekable();
|
||||||
|
let mut right_chars = right.chars().peekable();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match (left_chars.peek(), right_chars.peek()) {
|
||||||
|
(None, None) => return Ordering::Equal,
|
||||||
|
(None, Some(_)) => return Ordering::Less,
|
||||||
|
(Some(_), None) => return Ordering::Greater,
|
||||||
|
(Some(left_char), Some(right_char))
|
||||||
|
if left_char.is_ascii_digit() && right_char.is_ascii_digit() =>
|
||||||
|
{
|
||||||
|
let left_number = take_digits(&mut left_chars);
|
||||||
|
let right_number = take_digits(&mut right_chars);
|
||||||
|
let left_trimmed = left_number.trim_start_matches('0');
|
||||||
|
let right_trimmed = right_number.trim_start_matches('0');
|
||||||
|
let left_significant = if left_trimmed.is_empty() {
|
||||||
|
"0"
|
||||||
|
} else {
|
||||||
|
left_trimmed
|
||||||
|
};
|
||||||
|
let right_significant = if right_trimmed.is_empty() {
|
||||||
|
"0"
|
||||||
|
} else {
|
||||||
|
right_trimmed
|
||||||
|
};
|
||||||
|
|
||||||
|
let order = left_significant
|
||||||
|
.len()
|
||||||
|
.cmp(&right_significant.len())
|
||||||
|
.then_with(|| left_significant.cmp(right_significant))
|
||||||
|
.then_with(|| left_number.len().cmp(&right_number.len()));
|
||||||
|
if order != Ordering::Equal {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
let left_char = left_chars.next().unwrap();
|
||||||
|
let right_char = right_chars.next().unwrap();
|
||||||
|
let order = left_char
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(right_char.to_lowercase())
|
||||||
|
.then_with(|| left_char.cmp(&right_char));
|
||||||
|
if order != Ordering::Equal {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_digits<I>(characters: &mut std::iter::Peekable<I>) -> String
|
||||||
|
where
|
||||||
|
I: Iterator<Item = char>,
|
||||||
|
{
|
||||||
|
let mut digits = String::new();
|
||||||
|
while characters.peek().is_some_and(char::is_ascii_digit) {
|
||||||
|
digits.push(characters.next().unwrap());
|
||||||
|
}
|
||||||
|
digits
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn natural_order_places_two_before_ten() {
|
||||||
|
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
|
||||||
|
names.sort_by(|left, right| natural_cmp(left, right));
|
||||||
|
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn natural_order_handles_numbers_larger_than_integers() {
|
||||||
|
assert_eq!(
|
||||||
|
natural_cmp("9.zip", "123456789012345678901234567890.zip"),
|
||||||
|
Ordering::Less
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zip_extension_is_case_insensitive() {
|
||||||
|
assert!(is_zip_path(Path::new("comic.ZIP")));
|
||||||
|
assert!(!is_zip_path(Path::new("comic.cbz")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_times_sort_after_known_times() {
|
||||||
|
let now = SystemTime::now();
|
||||||
|
assert_eq!(
|
||||||
|
compare_optional_time(Some(now), None, SortDirection::Ascending),
|
||||||
|
Ordering::Less
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compare_optional_time(Some(now), None, SortDirection::Descending),
|
||||||
|
Ordering::Less
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/config.rs
Normal file
152
src/config.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub cache_quota_gb: u64,
|
||||||
|
pub fast_image_loading: bool,
|
||||||
|
pub locations: Vec<LocationProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cache_quota_gb: 10,
|
||||||
|
fast_image_loading: true,
|
||||||
|
locations: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LocationProfile {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: LocationKind,
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LocationKind {
|
||||||
|
Local,
|
||||||
|
Smb,
|
||||||
|
Ftp,
|
||||||
|
Sftp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn load_or_create(path: &Path) -> Result<Self> {
|
||||||
|
if path.exists() {
|
||||||
|
restrict_file_permissions(path)?;
|
||||||
|
let contents = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("설정 파일을 읽을 수 없습니다: {}", path.display()))?;
|
||||||
|
let config = toml::from_str(&contents)
|
||||||
|
.with_context(|| format!("설정 파일 형식이 잘못되었습니다: {}", path.display()))?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Self::default();
|
||||||
|
config.save(path)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
let temporary_path = temporary_path(path);
|
||||||
|
let contents = toml::to_string_pretty(self).context("설정을 직렬화할 수 없습니다")?;
|
||||||
|
|
||||||
|
let write_result = write_private_file(&temporary_path, contents.as_bytes())
|
||||||
|
.and_then(|()| fs::rename(&temporary_path, path).map_err(Into::into));
|
||||||
|
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temporary_path);
|
||||||
|
}
|
||||||
|
write_result
|
||||||
|
.with_context(|| format!("설정 파일을 저장할 수 없습니다: {}", path.display()))?;
|
||||||
|
restrict_file_permissions(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temporary_path(path: &Path) -> PathBuf {
|
||||||
|
let mut file_name = path
|
||||||
|
.file_name()
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| "config".into());
|
||||||
|
file_name.push(".tmp");
|
||||||
|
path.with_file_name(file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = options.open(path)?;
|
||||||
|
file.write_all(contents)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restrict_file_permissions(path: &Path) -> Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let mut permissions = fs::metadata(path)?.permissions();
|
||||||
|
permissions.set_mode(0o600);
|
||||||
|
fs::set_permissions(path, permissions)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_cache_quota_is_ten_gigabytes() {
|
||||||
|
assert_eq!(AppConfig::default().cache_quota_gb, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn location_credentials_round_trip_as_plain_text() {
|
||||||
|
let config = AppConfig {
|
||||||
|
cache_quota_gb: 0,
|
||||||
|
fast_image_loading: true,
|
||||||
|
locations: vec![LocationProfile {
|
||||||
|
name: "테스트".into(),
|
||||||
|
kind: LocationKind::Sftp,
|
||||||
|
address: "sftp://example.invalid/comics".into(),
|
||||||
|
username: "reader".into(),
|
||||||
|
password: "plain-password".into(),
|
||||||
|
domain: String::new(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = toml::to_string(&config).unwrap();
|
||||||
|
let decoded: AppConfig = toml::from_str(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded.locations[0].password, "plain-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_image_loading_is_enabled_for_existing_configs() {
|
||||||
|
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
||||||
|
assert!(decoded.fast_image_loading);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/database.rs
Normal file
125
src/database.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
connection: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let connection = Connection::open(path)
|
||||||
|
.with_context(|| format!("데이터베이스를 열 수 없습니다: {}", path.display()))?;
|
||||||
|
connection.execute_batch(
|
||||||
|
"PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cached_entries (
|
||||||
|
location_id TEXT NOT NULL,
|
||||||
|
parent_uri TEXT NOT NULL,
|
||||||
|
uri TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
entry_kind INTEGER NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
modified_at INTEGER,
|
||||||
|
created_at INTEGER,
|
||||||
|
etag TEXT,
|
||||||
|
refreshed_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (location_id, uri)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS cached_entries_parent
|
||||||
|
ON cached_entries (location_id, parent_uri);
|
||||||
|
CREATE INDEX IF NOT EXISTS cached_entries_name
|
||||||
|
ON cached_entries (location_id, name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||||
|
archive_uri TEXT PRIMARY KEY,
|
||||||
|
page_index INTEGER NOT NULL,
|
||||||
|
page_count INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS zip_cache (
|
||||||
|
archive_uri TEXT PRIMARY KEY,
|
||||||
|
local_path TEXT NOT NULL UNIQUE,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
source_size INTEGER,
|
||||||
|
source_modified_at INTEGER,
|
||||||
|
source_etag TEXT,
|
||||||
|
last_accessed_at INTEGER NOT NULL
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self { connection })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_progress(&self, archive_uri: &str) -> Result<Option<usize>> {
|
||||||
|
let page = self
|
||||||
|
.connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT page_index FROM reading_progress WHERE archive_uri = ?1",
|
||||||
|
[archive_uri],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
Ok(page.and_then(|page| usize::try_from(page).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_progress(
|
||||||
|
&self,
|
||||||
|
archive_uri: &str,
|
||||||
|
page_index: usize,
|
||||||
|
page_count: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let updated_at = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO reading_progress
|
||||||
|
(archive_uri, page_index, page_count, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(archive_uri) DO UPDATE SET
|
||||||
|
page_index = excluded.page_index,
|
||||||
|
page_count = excluded.page_count,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
params![
|
||||||
|
archive_uri,
|
||||||
|
page_index as i64,
|
||||||
|
page_count as i64,
|
||||||
|
updated_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO app_state (key, value) VALUES ('last_archive_uri', ?1)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
[archive_uri],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_progress_round_trips_and_updates() {
|
||||||
|
let database = Database::open(Path::new(":memory:")).unwrap();
|
||||||
|
assert_eq!(database.load_progress("test.zip").unwrap(), None);
|
||||||
|
|
||||||
|
database.save_progress("test.zip", 3, 20).unwrap();
|
||||||
|
assert_eq!(database.load_progress("test.zip").unwrap(), Some(3));
|
||||||
|
|
||||||
|
database.save_progress("test.zip", 9, 20).unwrap();
|
||||||
|
assert_eq!(database.load_progress("test.zip").unwrap(), Some(9));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main.rs
Normal file
43
src/main.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
mod archive;
|
||||||
|
mod browser;
|
||||||
|
mod config;
|
||||||
|
mod database;
|
||||||
|
mod paths;
|
||||||
|
mod source;
|
||||||
|
mod viewer;
|
||||||
|
mod window;
|
||||||
|
mod zip_cache;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
const APP_ID: &str = "io.github.comicviewer.Comicviewer";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let application = gtk::Application::builder().application_id(APP_ID).build();
|
||||||
|
|
||||||
|
application.connect_activate(|application| {
|
||||||
|
if let Err(error) = activate(application) {
|
||||||
|
eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}");
|
||||||
|
application.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
application.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(application: >k::Application) -> Result<()> {
|
||||||
|
let app_paths = paths::AppPaths::discover()?;
|
||||||
|
let config = config::AppConfig::load_or_create(&app_paths.config_file)?;
|
||||||
|
let database = database::Database::open(&app_paths.database_file)?;
|
||||||
|
let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?;
|
||||||
|
let window = window::ComicWindow::new(
|
||||||
|
application,
|
||||||
|
config,
|
||||||
|
app_paths.config_file,
|
||||||
|
database,
|
||||||
|
zip_cache,
|
||||||
|
)?;
|
||||||
|
window.present();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
67
src/paths.rs
Normal file
67
src/paths.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AppPaths {
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
pub database_file: PathBuf,
|
||||||
|
pub zip_cache_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppPaths {
|
||||||
|
pub fn discover() -> Result<Self> {
|
||||||
|
let home = env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("HOME 환경 변수가 설정되어 있지 않습니다"))?;
|
||||||
|
|
||||||
|
let config_root = xdg_dir("XDG_CONFIG_HOME", &home, ".config");
|
||||||
|
let data_root = xdg_dir("XDG_DATA_HOME", &home, ".local/share");
|
||||||
|
let cache_root = xdg_dir("XDG_CACHE_HOME", &home, ".cache");
|
||||||
|
|
||||||
|
let config_dir = config_root.join("comicviewer");
|
||||||
|
let data_dir = data_root.join("comicviewer");
|
||||||
|
let cache_dir = cache_root.join("comicviewer");
|
||||||
|
|
||||||
|
create_private_dir(&config_dir)?;
|
||||||
|
create_private_dir(&data_dir)?;
|
||||||
|
create_private_dir(&cache_dir)?;
|
||||||
|
let zip_cache_dir = cache_dir.join("archives");
|
||||||
|
create_private_dir(&zip_cache_dir)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config_file: config_dir.join("config.toml"),
|
||||||
|
database_file: data_dir.join("comicviewer.sqlite3"),
|
||||||
|
zip_cache_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xdg_dir(variable: &str, home: &Path, fallback: &str) -> PathBuf {
|
||||||
|
env::var_os(variable)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home.join(fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_private_dir(path: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(path)
|
||||||
|
.with_context(|| format!("디렉터리를 만들 수 없습니다: {}", path.display()))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let mut permissions = fs::metadata(path)?.permissions();
|
||||||
|
permissions.set_mode(0o700);
|
||||||
|
fs::set_permissions(path, permissions)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
bail!("데이터 경로가 디렉터리가 아닙니다: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
275
src/source.rs
Normal file
275
src/source.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use gtk::gio;
|
||||||
|
use gtk::gio::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
|
||||||
|
use crate::browser::{BrowserEntry, EntryKind, is_zip_path};
|
||||||
|
use crate::config::{LocationKind, LocationProfile};
|
||||||
|
|
||||||
|
const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
|
||||||
|
|
||||||
|
pub fn root_file(profile: &LocationProfile) -> Result<gio::File> {
|
||||||
|
validate_profile(profile)?;
|
||||||
|
Ok(file_from_user_input(profile.kind, &profile.address))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_from_user_input(kind: LocationKind, input: &str) -> gio::File {
|
||||||
|
match kind {
|
||||||
|
LocationKind::Local => gio::File::for_path(input),
|
||||||
|
_ => gio::File::for_uri(input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_remote_uri(uri: &str) -> String {
|
||||||
|
percent_decode_str(uri).decode_utf8_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_within_root(file: &gio::File, root: &gio::File) -> bool {
|
||||||
|
if file.equal(root) || file.has_prefix(root) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut root_uri = root.uri().to_string();
|
||||||
|
if !root_uri.ends_with('/') {
|
||||||
|
root_uri.push('/');
|
||||||
|
}
|
||||||
|
file.uri().starts_with(&root_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parent_within_root(current: &gio::File, root: &gio::File) -> Option<gio::File> {
|
||||||
|
if current.equal(root) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(parent) = current.parent()
|
||||||
|
&& is_within_root(&parent, root)
|
||||||
|
{
|
||||||
|
return Some(parent);
|
||||||
|
}
|
||||||
|
is_within_root(current, root).then(|| root.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_profile(profile: &LocationProfile) -> Result<()> {
|
||||||
|
if profile.name.trim().is_empty() || profile.address.trim().is_empty() {
|
||||||
|
bail!("위치 이름과 주소를 입력해 주세요.");
|
||||||
|
}
|
||||||
|
match profile.kind {
|
||||||
|
LocationKind::Local if !Path::new(&profile.address).is_absolute() => {
|
||||||
|
bail!("로컬 위치는 절대 경로여야 합니다.")
|
||||||
|
}
|
||||||
|
LocationKind::Smb if !profile.address.starts_with("smb://") => {
|
||||||
|
bail!("SMB 주소는 smb://로 시작해야 합니다.")
|
||||||
|
}
|
||||||
|
LocationKind::Ftp if !profile.address.starts_with("ftp://") => {
|
||||||
|
bail!("FTP 주소는 ftp://로 시작해야 합니다.")
|
||||||
|
}
|
||||||
|
LocationKind::Sftp if !profile.address.starts_with("sftp://") => {
|
||||||
|
bail!("SFTP 주소는 sftp://로 시작해야 합니다.")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enumerate_directory<F>(
|
||||||
|
profile: &LocationProfile,
|
||||||
|
directory_uri: &str,
|
||||||
|
operation: >k::MountOperation,
|
||||||
|
mut progress: F,
|
||||||
|
) -> Result<Vec<BrowserEntry>>
|
||||||
|
where
|
||||||
|
F: FnMut(usize),
|
||||||
|
{
|
||||||
|
let directory = gio::File::for_uri(directory_uri);
|
||||||
|
let enumerator = match enumerate_with_mount_retry(
|
||||||
|
&directory,
|
||||||
|
operation,
|
||||||
|
profile.kind != LocationKind::Local,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(enumerator) => enumerator,
|
||||||
|
Err(error) if error.matches(gio::IOErrorEnum::NotSupported) => {
|
||||||
|
bail!("원격 파일 시스템 백엔드가 없습니다. gvfs와 gvfs-smb를 설치해 주세요: {error}")
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error)
|
||||||
|
.with_context(|| format!("폴더에 접근할 수 없습니다: {directory_uri}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut scanned = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let batch = enumerator
|
||||||
|
.next_files_future(128, glib::Priority::DEFAULT)
|
||||||
|
.await?;
|
||||||
|
if batch.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
scanned += batch.len();
|
||||||
|
progress(scanned);
|
||||||
|
for info in batch {
|
||||||
|
let kind = match info.file_type() {
|
||||||
|
gio::FileType::Directory => EntryKind::Directory,
|
||||||
|
gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory,
|
||||||
|
gio::FileType::Regular if is_zip_path(Path::new(info.display_name().as_str())) => {
|
||||||
|
EntryKind::ZipArchive
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let child = enumerator.child(&info);
|
||||||
|
let uri = info
|
||||||
|
.attribute_string("standard::target-uri")
|
||||||
|
.map_or_else(|| child.uri().into(), Into::into);
|
||||||
|
entries.push(BrowserEntry {
|
||||||
|
uri,
|
||||||
|
name: info.display_name().into(),
|
||||||
|
kind,
|
||||||
|
size: info
|
||||||
|
.has_attribute("standard::size")
|
||||||
|
.then(|| info.size())
|
||||||
|
.filter(|size| *size >= 0)
|
||||||
|
.map(|size| size as u64),
|
||||||
|
modified: attribute_time(&info, "time::modified"),
|
||||||
|
created: attribute_time(&info, "time::created"),
|
||||||
|
etag: info
|
||||||
|
.has_attribute("etag::value")
|
||||||
|
.then(|| info.etag())
|
||||||
|
.flatten()
|
||||||
|
.map(Into::into),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mount_operation(
|
||||||
|
profile: &LocationProfile,
|
||||||
|
parent: &impl glib::object::IsA<gtk::Window>,
|
||||||
|
) -> gtk::MountOperation {
|
||||||
|
let operation = gtk::MountOperation::new(Some(parent));
|
||||||
|
let username = profile.username.clone();
|
||||||
|
let password = profile.password.clone();
|
||||||
|
let domain = profile.domain.clone();
|
||||||
|
operation.connect_ask_password(move |operation, _, _, _, flags| {
|
||||||
|
let missing_username =
|
||||||
|
flags.contains(gio::AskPasswordFlags::NEED_USERNAME) && username.is_empty();
|
||||||
|
let missing_password =
|
||||||
|
flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) && password.is_empty();
|
||||||
|
if missing_username || missing_password {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) {
|
||||||
|
operation.set_username(Some(&username));
|
||||||
|
}
|
||||||
|
if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) {
|
||||||
|
operation.set_password(Some(&password));
|
||||||
|
}
|
||||||
|
if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) {
|
||||||
|
operation.set_domain(Some(&domain));
|
||||||
|
}
|
||||||
|
operation.set_anonymous(false);
|
||||||
|
operation.set_password_save(gio::PasswordSave::Never);
|
||||||
|
operation.stop_signal_emission_by_name("ask-password");
|
||||||
|
operation.reply(gio::MountOperationResult::Handled);
|
||||||
|
});
|
||||||
|
operation
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_with_mount_retry(
|
||||||
|
directory: &gio::File,
|
||||||
|
operation: >k::MountOperation,
|
||||||
|
allow_mount: bool,
|
||||||
|
) -> Result<gio::FileEnumerator, glib::Error> {
|
||||||
|
let first = directory
|
||||||
|
.enumerate_children_future(
|
||||||
|
ATTRIBUTES,
|
||||||
|
gio::FileQueryInfoFlags::NONE,
|
||||||
|
glib::Priority::DEFAULT,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let Err(error) = first else {
|
||||||
|
return first;
|
||||||
|
};
|
||||||
|
if !allow_mount || !error.matches(gio::IOErrorEnum::NotMounted) {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
match directory
|
||||||
|
.mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
|
||||||
|
directory
|
||||||
|
.enumerate_children_future(
|
||||||
|
ATTRIBUTES,
|
||||||
|
gio::FileQueryInfoFlags::NONE,
|
||||||
|
glib::Priority::DEFAULT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option<std::time::SystemTime> {
|
||||||
|
info.has_attribute(attribute)
|
||||||
|
.then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_protocol_schemes() {
|
||||||
|
let profile = LocationProfile {
|
||||||
|
name: "서버".into(),
|
||||||
|
kind: LocationKind::Sftp,
|
||||||
|
address: "ftp://example.invalid/comics".into(),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
domain: String::new(),
|
||||||
|
};
|
||||||
|
assert!(validate_profile(&profile).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smb_paths_stay_inside_saved_root() {
|
||||||
|
let root = gio::File::for_uri("smb://example.invalid/ComicsA");
|
||||||
|
let child = gio::File::for_uri("smb://example.invalid/ComicsA/favorites");
|
||||||
|
let sibling = gio::File::for_uri("smb://example.invalid/ComicsB");
|
||||||
|
|
||||||
|
assert!(is_within_root(&child, &root));
|
||||||
|
assert!(!is_within_root(&sibling, &root));
|
||||||
|
assert!(parent_within_root(&child, &root).is_some_and(|parent| parent.equal(&root)));
|
||||||
|
assert!(parent_within_root(&root, &root).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mounted_share_can_return_to_smb_server_root() {
|
||||||
|
let root = gio::File::for_uri("smb://example.invalid");
|
||||||
|
let share = gio::File::for_uri("smb://example.invalid/ComicsA");
|
||||||
|
|
||||||
|
assert!(is_within_root(&share, &root));
|
||||||
|
assert!(parent_within_root(&share, &root).is_some_and(|parent| parent.equal(&root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ftp_parse_name_round_trips_utf8_paths() {
|
||||||
|
let encoded =
|
||||||
|
gio::File::for_uri("ftp://example.invalid/%ED%95%9C%EA%B8%80%20%ED%8F%B4%EB%8D%94");
|
||||||
|
let display_name = display_remote_uri(encoded.uri().as_str());
|
||||||
|
assert!(display_name.contains("한글 폴더"));
|
||||||
|
|
||||||
|
let decoded = file_from_user_input(LocationKind::Ftp, &display_name);
|
||||||
|
assert!(decoded.equal(&encoded));
|
||||||
|
|
||||||
|
let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str());
|
||||||
|
assert!(encoded_input.equal(&encoded));
|
||||||
|
}
|
||||||
|
}
|
||||||
963
src/viewer.rs
Normal file
963
src/viewer.rs
Normal file
|
|
@ -0,0 +1,963 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::{self, TryRecvError};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gtk::gdk;
|
||||||
|
use gtk::glib;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use crate::archive::{ArchivePage, DecodedPage, decode_page, list_pages};
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::zip_cache::ArchiveSource;
|
||||||
|
|
||||||
|
const CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024;
|
||||||
|
const CACHE_LIMIT_PAGES: usize = 16;
|
||||||
|
const FAST_CACHE_LIMIT_BYTES: usize = 1024 * 1024 * 1024;
|
||||||
|
const FAST_CACHE_LIMIT_PAGES: usize = 64;
|
||||||
|
|
||||||
|
pub struct ViewerWindow;
|
||||||
|
|
||||||
|
impl ViewerWindow {
|
||||||
|
pub fn open(
|
||||||
|
application: >k::Application,
|
||||||
|
source: ArchiveSource,
|
||||||
|
database: Rc<Database>,
|
||||||
|
fast_image_loading: bool,
|
||||||
|
) {
|
||||||
|
let title = source
|
||||||
|
.local_path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "Comicviewer".into());
|
||||||
|
let window = gtk::ApplicationWindow::builder()
|
||||||
|
.application(application)
|
||||||
|
.title(&title)
|
||||||
|
.default_width(1100)
|
||||||
|
.default_height(850)
|
||||||
|
.build();
|
||||||
|
let header = gtk::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
||||||
|
window.set_titlebar(Some(&header));
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
toolbar.set_margin_top(8);
|
||||||
|
toolbar.set_margin_bottom(8);
|
||||||
|
toolbar.set_margin_start(8);
|
||||||
|
toolbar.set_margin_end(8);
|
||||||
|
let previous_button = gtk::Button::builder()
|
||||||
|
.icon_name("go-previous-symbolic")
|
||||||
|
.tooltip_text("이전")
|
||||||
|
.build();
|
||||||
|
let next_button = gtk::Button::builder()
|
||||||
|
.icon_name("go-next-symbolic")
|
||||||
|
.tooltip_text("다음")
|
||||||
|
.build();
|
||||||
|
let shift_back_button = gtk::Button::with_label("한 장 이전");
|
||||||
|
let shift_forward_button = gtk::Button::with_label("한 장 다음");
|
||||||
|
let page_label = gtk::Label::new(Some("- / -"));
|
||||||
|
page_label.set_hexpand(true);
|
||||||
|
let direction_dropdown = gtk::DropDown::from_strings(&["좌→우", "우→좌"]);
|
||||||
|
direction_dropdown.set_selected(1);
|
||||||
|
let mode_dropdown = gtk::DropDown::from_strings(&[
|
||||||
|
"100%",
|
||||||
|
"폭 맞춤",
|
||||||
|
"높이 맞춤",
|
||||||
|
"2페이지",
|
||||||
|
"이어서 보기",
|
||||||
|
]);
|
||||||
|
mode_dropdown.set_selected(1);
|
||||||
|
toolbar.append(&previous_button);
|
||||||
|
toolbar.append(&shift_back_button);
|
||||||
|
toolbar.append(&shift_forward_button);
|
||||||
|
toolbar.append(&next_button);
|
||||||
|
toolbar.append(&page_label);
|
||||||
|
toolbar.append(&direction_dropdown);
|
||||||
|
toolbar.append(&mode_dropdown);
|
||||||
|
|
||||||
|
let single_picture = page_picture();
|
||||||
|
let single_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&single_picture)
|
||||||
|
.build();
|
||||||
|
let left_picture = page_picture();
|
||||||
|
let right_picture = page_picture();
|
||||||
|
let spread_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
|
spread_box.set_homogeneous(true);
|
||||||
|
spread_box.append(&left_picture);
|
||||||
|
spread_box.append(&right_picture);
|
||||||
|
|
||||||
|
let continuous_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
|
continuous_box.set_halign(gtk::Align::Fill);
|
||||||
|
let continuous_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&continuous_box)
|
||||||
|
.build();
|
||||||
|
let stack = gtk::Stack::new();
|
||||||
|
stack.set_hexpand(true);
|
||||||
|
stack.set_vexpand(true);
|
||||||
|
stack.add_named(&single_scrolled, Some("single"));
|
||||||
|
stack.add_named(&spread_box, Some("spread"));
|
||||||
|
stack.add_named(&continuous_scrolled, Some("continuous"));
|
||||||
|
|
||||||
|
let spinner = gtk::Spinner::new();
|
||||||
|
spinner.set_halign(gtk::Align::Center);
|
||||||
|
spinner.set_valign(gtk::Align::Center);
|
||||||
|
let overlay = gtk::Overlay::new();
|
||||||
|
overlay.set_child(Some(&stack));
|
||||||
|
overlay.add_overlay(&spinner);
|
||||||
|
let status_label = gtk::Label::builder()
|
||||||
|
.label("ZIP 파일을 읽는 중...")
|
||||||
|
.xalign(0.0)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(6)
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_end(8)
|
||||||
|
.build();
|
||||||
|
root.append(&toolbar);
|
||||||
|
root.append(&overlay);
|
||||||
|
root.append(&status_label);
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
let controller = Rc::new(ViewerController {
|
||||||
|
window,
|
||||||
|
source,
|
||||||
|
database,
|
||||||
|
stack,
|
||||||
|
single_picture,
|
||||||
|
left_picture,
|
||||||
|
right_picture,
|
||||||
|
continuous_box,
|
||||||
|
continuous_scrolled,
|
||||||
|
spinner,
|
||||||
|
page_label,
|
||||||
|
status_label,
|
||||||
|
previous_button,
|
||||||
|
next_button,
|
||||||
|
shift_back_button,
|
||||||
|
shift_forward_button,
|
||||||
|
mode_dropdown,
|
||||||
|
direction_dropdown,
|
||||||
|
fast_image_loading,
|
||||||
|
state: RefCell::new(ViewerState {
|
||||||
|
pages: Vec::new(),
|
||||||
|
anchor: 0,
|
||||||
|
cache: HashMap::new(),
|
||||||
|
requested: HashSet::new(),
|
||||||
|
pending: VecDeque::new(),
|
||||||
|
in_flight: 0,
|
||||||
|
failed: HashMap::new(),
|
||||||
|
access_clock: 0,
|
||||||
|
continuous_rows: Vec::new(),
|
||||||
|
continuous_bound: HashSet::new(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
controller.connect_signals();
|
||||||
|
controller.apply_view_mode();
|
||||||
|
controller.window.present();
|
||||||
|
controller.load_archive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ViewMode {
|
||||||
|
ActualSize,
|
||||||
|
FitWidth,
|
||||||
|
FitHeight,
|
||||||
|
TwoPage,
|
||||||
|
Continuous,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachedPage {
|
||||||
|
texture: gdk::MemoryTexture,
|
||||||
|
cost: usize,
|
||||||
|
last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContinuousRow {
|
||||||
|
frame: gtk::AspectFrame,
|
||||||
|
picture: gtk::Picture,
|
||||||
|
ratio: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ViewerState {
|
||||||
|
pages: Vec<ArchivePage>,
|
||||||
|
anchor: usize,
|
||||||
|
cache: HashMap<usize, CachedPage>,
|
||||||
|
requested: HashSet<usize>,
|
||||||
|
pending: VecDeque<usize>,
|
||||||
|
in_flight: usize,
|
||||||
|
failed: HashMap<usize, String>,
|
||||||
|
access_clock: u64,
|
||||||
|
continuous_rows: Vec<ContinuousRow>,
|
||||||
|
continuous_bound: HashSet<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ViewerController {
|
||||||
|
window: gtk::ApplicationWindow,
|
||||||
|
source: ArchiveSource,
|
||||||
|
database: Rc<Database>,
|
||||||
|
stack: gtk::Stack,
|
||||||
|
single_picture: gtk::Picture,
|
||||||
|
left_picture: gtk::Picture,
|
||||||
|
right_picture: gtk::Picture,
|
||||||
|
continuous_box: gtk::Box,
|
||||||
|
continuous_scrolled: gtk::ScrolledWindow,
|
||||||
|
spinner: gtk::Spinner,
|
||||||
|
page_label: gtk::Label,
|
||||||
|
status_label: gtk::Label,
|
||||||
|
previous_button: gtk::Button,
|
||||||
|
next_button: gtk::Button,
|
||||||
|
shift_back_button: gtk::Button,
|
||||||
|
shift_forward_button: gtk::Button,
|
||||||
|
mode_dropdown: gtk::DropDown,
|
||||||
|
direction_dropdown: gtk::DropDown,
|
||||||
|
fast_image_loading: bool,
|
||||||
|
state: RefCell<ViewerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewerController {
|
||||||
|
fn connect_signals(self: &Rc<Self>) {
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.previous_button
|
||||||
|
.connect_clicked(move |_| controller.navigate(false));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.next_button
|
||||||
|
.connect_clicked(move |_| controller.navigate(true));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.shift_back_button
|
||||||
|
.connect_clicked(move |_| controller.shift_pairing(-1));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.shift_forward_button
|
||||||
|
.connect_clicked(move |_| controller.shift_pairing(1));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.mode_dropdown
|
||||||
|
.connect_selected_notify(move |_| controller.apply_view_mode());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.direction_dropdown
|
||||||
|
.connect_selected_notify(move |_| controller.render());
|
||||||
|
|
||||||
|
let adjustment = self.continuous_scrolled.vadjustment();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
adjustment.connect_value_changed(move |_| controller.continuous_scrolled());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
adjustment.connect_changed(move |_| controller.continuous_scrolled());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.continuous_scrolled
|
||||||
|
.connect_notify_local(Some("width"), move |_, _| {
|
||||||
|
controller.resize_continuous_rows();
|
||||||
|
});
|
||||||
|
|
||||||
|
let scroll_controller = gtk::EventControllerScroll::new(
|
||||||
|
gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE,
|
||||||
|
);
|
||||||
|
scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
scroll_controller.connect_scroll(move |_, _, delta_y| {
|
||||||
|
if controller.view_mode() == ViewMode::Continuous || delta_y == 0.0 {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
controller.navigate(delta_y > 0.0);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
self.stack.add_controller(scroll_controller);
|
||||||
|
|
||||||
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
key_controller.connect_key_pressed(move |_, key, _, _| match key {
|
||||||
|
gdk::Key::Left | gdk::Key::Page_Up => {
|
||||||
|
controller.navigate(false);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => {
|
||||||
|
controller.navigate(true);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ if key
|
||||||
|
.to_unicode()
|
||||||
|
.is_some_and(|character| character.eq_ignore_ascii_case(&'f')) =>
|
||||||
|
{
|
||||||
|
controller.toggle_fullscreen();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
gdk::Key::Escape if controller.window.is_fullscreen() => {
|
||||||
|
controller.window.unfullscreen();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ => glib::Propagation::Proceed,
|
||||||
|
});
|
||||||
|
self.window.add_controller(key_controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_fullscreen(&self) {
|
||||||
|
if self.window.is_fullscreen() {
|
||||||
|
self.window.unfullscreen();
|
||||||
|
} else {
|
||||||
|
self.window.fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_archive(self: &Rc<Self>) {
|
||||||
|
self.spinner.start();
|
||||||
|
let path = self.source.local_path.clone();
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = sender.send(list_pages(&path).map_err(|error| format!("{error:#}")));
|
||||||
|
});
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
||||||
|
match receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(controller) = controller.upgrade() {
|
||||||
|
controller.finish_archive_load(result);
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_archive_load(self: &Rc<Self>, result: Result<Vec<ArchivePage>, String>) {
|
||||||
|
match result {
|
||||||
|
Ok(pages) if pages.is_empty() => {
|
||||||
|
self.spinner.stop();
|
||||||
|
self.status_label
|
||||||
|
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
|
||||||
|
}
|
||||||
|
Ok(pages) => {
|
||||||
|
let saved = self
|
||||||
|
.database
|
||||||
|
.load_progress(&self.source.source_uri)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(pages.len() - 1);
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.pages = pages;
|
||||||
|
state.anchor = saved;
|
||||||
|
drop(state);
|
||||||
|
self.build_continuous_rows();
|
||||||
|
self.render();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.spinner.stop();
|
||||||
|
self.status_label.set_text(&error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_continuous_rows(self: &Rc<Self>) {
|
||||||
|
while let Some(child) = self.continuous_box.first_child() {
|
||||||
|
self.continuous_box.remove(&child);
|
||||||
|
}
|
||||||
|
let count = self.state.borrow().pages.len();
|
||||||
|
let mut rows = Vec::with_capacity(count);
|
||||||
|
let default_ratio = 2.0 / 3.0;
|
||||||
|
let width = self.continuous_content_width();
|
||||||
|
for _ in 0..count {
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
picture.set_vexpand(false);
|
||||||
|
picture.set_can_shrink(true);
|
||||||
|
picture.set_size_request(1, 1);
|
||||||
|
let frame = gtk::AspectFrame::new(0.5, 0.5, default_ratio, false);
|
||||||
|
frame.set_hexpand(true);
|
||||||
|
frame.set_size_request(-1, row_height(width, default_ratio));
|
||||||
|
frame.set_child(Some(&picture));
|
||||||
|
self.continuous_box.append(&frame);
|
||||||
|
rows.push(ContinuousRow {
|
||||||
|
frame,
|
||||||
|
picture,
|
||||||
|
ratio: default_ratio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.continuous_rows = rows;
|
||||||
|
state.continuous_bound.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_mode(&self) -> ViewMode {
|
||||||
|
match self.mode_dropdown.selected() {
|
||||||
|
0 => ViewMode::ActualSize,
|
||||||
|
2 => ViewMode::FitHeight,
|
||||||
|
3 => ViewMode::TwoPage,
|
||||||
|
4 => ViewMode::Continuous,
|
||||||
|
_ => ViewMode::FitWidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_view_mode(self: &Rc<Self>) {
|
||||||
|
let mode = self.view_mode();
|
||||||
|
self.shift_back_button
|
||||||
|
.set_visible(mode == ViewMode::TwoPage);
|
||||||
|
self.shift_forward_button
|
||||||
|
.set_visible(mode == ViewMode::TwoPage);
|
||||||
|
self.direction_dropdown
|
||||||
|
.set_visible(mode == ViewMode::TwoPage);
|
||||||
|
match mode {
|
||||||
|
ViewMode::TwoPage => self.stack.set_visible_child_name("spread"),
|
||||||
|
ViewMode::Continuous => self.stack.set_visible_child_name("continuous"),
|
||||||
|
_ => self.stack.set_visible_child_name("single"),
|
||||||
|
}
|
||||||
|
if mode == ViewMode::Continuous {
|
||||||
|
self.single_picture.set_paintable(gdk::Paintable::NONE);
|
||||||
|
self.left_picture.set_paintable(gdk::Paintable::NONE);
|
||||||
|
self.right_picture.set_paintable(gdk::Paintable::NONE);
|
||||||
|
self.schedule_scroll_to_anchor();
|
||||||
|
} else {
|
||||||
|
self.clear_continuous_paintables();
|
||||||
|
}
|
||||||
|
let actual = mode == ViewMode::ActualSize;
|
||||||
|
self.single_picture.set_can_shrink(!actual);
|
||||||
|
self.single_picture.set_hexpand(mode == ViewMode::FitWidth);
|
||||||
|
self.single_picture.set_vexpand(mode == ViewMode::FitHeight);
|
||||||
|
self.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate(self: &Rc<Self>, forward: bool) {
|
||||||
|
let step = if self.view_mode() == ViewMode::TwoPage {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.pages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let next = if forward {
|
||||||
|
state.anchor.saturating_add(step).min(state.pages.len() - 1)
|
||||||
|
} else {
|
||||||
|
state.anchor.saturating_sub(step)
|
||||||
|
};
|
||||||
|
if next == state.anchor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.anchor = next;
|
||||||
|
drop(state);
|
||||||
|
self.render();
|
||||||
|
if self.view_mode() == ViewMode::Continuous {
|
||||||
|
self.schedule_scroll_to_anchor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift_pairing(self: &Rc<Self>, offset: isize) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.pages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.anchor = state
|
||||||
|
.anchor
|
||||||
|
.saturating_add_signed(offset)
|
||||||
|
.min(state.pages.len() - 1);
|
||||||
|
drop(state);
|
||||||
|
self.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(self: &Rc<Self>) {
|
||||||
|
let visible = self.visible_indices();
|
||||||
|
if visible.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.view_mode() {
|
||||||
|
ViewMode::TwoPage => self.render_spread(&visible),
|
||||||
|
ViewMode::Continuous => self.render_continuous(&visible),
|
||||||
|
_ => self.render_single(visible[0]),
|
||||||
|
}
|
||||||
|
self.update_navigation();
|
||||||
|
self.request_pages(self.prefetch_indices(&visible));
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let _ =
|
||||||
|
self.database
|
||||||
|
.save_progress(&self.source.source_uri, state.anchor, state.pages.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_indices(&self) -> Vec<usize> {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
if state.pages.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.view_mode() == ViewMode::TwoPage {
|
||||||
|
spread_indices(state.anchor, state.pages.len()).to_vec()
|
||||||
|
} else if self.view_mode() == ViewMode::Continuous {
|
||||||
|
drop(state);
|
||||||
|
self.continuous_visible_indices()
|
||||||
|
} else {
|
||||||
|
vec![state.anchor]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefetch_indices(&self, visible: &[usize]) -> Vec<usize> {
|
||||||
|
let count = self.state.borrow().pages.len();
|
||||||
|
prefetch_window(visible, count, self.fast_image_loading)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_pages(self: &Rc<Self>, indices: Vec<usize>) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let mut prioritized = VecDeque::new();
|
||||||
|
for index in indices {
|
||||||
|
if index >= state.pages.len()
|
||||||
|
|| state.cache.contains_key(&index)
|
||||||
|
|| state.failed.contains_key(&index)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if state.requested.contains(&index) {
|
||||||
|
if state.pending.contains(&index) {
|
||||||
|
prioritized.push_back(index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.requested.insert(index);
|
||||||
|
prioritized.push_back(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prioritized_set: HashSet<_> = prioritized.iter().copied().collect();
|
||||||
|
state
|
||||||
|
.pending
|
||||||
|
.retain(|index| !prioritized_set.contains(index));
|
||||||
|
prioritized.append(&mut state.pending);
|
||||||
|
state.pending = prioritized;
|
||||||
|
drop(state);
|
||||||
|
self.pump_requests();
|
||||||
|
self.update_spinner();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_requests(self: &Rc<Self>) {
|
||||||
|
let concurrency = if self.fast_image_loading { 4 } else { 2 };
|
||||||
|
while let Some((index, page)) = {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.in_flight >= concurrency {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
state.pending.pop_front().map(|index| {
|
||||||
|
state.in_flight += 1;
|
||||||
|
(index, state.pages[index].clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
let path = self.source.local_path.clone();
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = decode_page(&path, &page).map_err(|error| format!("{error:#}"));
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
||||||
|
match receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(controller) = controller.upgrade() {
|
||||||
|
controller.finish_page_load(index, result);
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_page_load(self: &Rc<Self>, index: usize, result: Result<DecodedPage, String>) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.requested.remove(&index);
|
||||||
|
state.in_flight = state.in_flight.saturating_sub(1);
|
||||||
|
let mut decoded_ratio = None;
|
||||||
|
match result {
|
||||||
|
Ok(page) => {
|
||||||
|
let ratio = page.width as f32 / page.height as f32;
|
||||||
|
decoded_ratio = Some(ratio);
|
||||||
|
let cost = page.rgba.len();
|
||||||
|
let bytes = glib::Bytes::from_owned(page.rgba);
|
||||||
|
let texture = gdk::MemoryTexture::new(
|
||||||
|
page.width as i32,
|
||||||
|
page.height as i32,
|
||||||
|
gdk::MemoryFormat::R8g8b8a8,
|
||||||
|
&bytes,
|
||||||
|
page.width as usize * 4,
|
||||||
|
);
|
||||||
|
state.access_clock = state.access_clock.wrapping_add(1);
|
||||||
|
let last_used = state.access_clock;
|
||||||
|
state.cache.insert(
|
||||||
|
index,
|
||||||
|
CachedPage {
|
||||||
|
texture,
|
||||||
|
cost,
|
||||||
|
last_used,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
state.failed.insert(index, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
if let Some(ratio) = decoded_ratio {
|
||||||
|
let frame = {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.continuous_rows.get_mut(index).map(|row| {
|
||||||
|
row.ratio = ratio;
|
||||||
|
row.frame.clone()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(frame) = frame {
|
||||||
|
frame.set_ratio(ratio);
|
||||||
|
frame.set_size_request(-1, row_height(self.continuous_content_width(), ratio));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.evict_cache();
|
||||||
|
self.render_loaded_pages();
|
||||||
|
self.update_spinner();
|
||||||
|
self.pump_requests();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_loaded_pages(&self) {
|
||||||
|
let visible = self.visible_indices();
|
||||||
|
match self.view_mode() {
|
||||||
|
ViewMode::TwoPage => self.render_spread(&visible),
|
||||||
|
ViewMode::Continuous => self.render_continuous(&visible),
|
||||||
|
_ if !visible.is_empty() => self.render_single(visible[0]),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_single(&self, index: usize) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
self.single_picture
|
||||||
|
.set_paintable(state.cache.get(&index).map(|page| &page.texture));
|
||||||
|
self.status_label.set_text(&state.pages[index].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_spread(&self, indices: &[usize]) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let first = indices.first().and_then(|index| state.cache.get(index));
|
||||||
|
let second = indices.get(1).and_then(|index| state.cache.get(index));
|
||||||
|
if self.direction_dropdown.selected() == 1 {
|
||||||
|
self.left_picture
|
||||||
|
.set_paintable(second.map(|page| &page.texture));
|
||||||
|
self.right_picture
|
||||||
|
.set_paintable(first.map(|page| &page.texture));
|
||||||
|
} else {
|
||||||
|
self.left_picture
|
||||||
|
.set_paintable(first.map(|page| &page.texture));
|
||||||
|
self.right_picture
|
||||||
|
.set_paintable(second.map(|page| &page.texture));
|
||||||
|
}
|
||||||
|
self.status_label.set_text(
|
||||||
|
&indices
|
||||||
|
.iter()
|
||||||
|
.map(|index| state.pages[*index].name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_continuous(&self, visible: &[usize]) {
|
||||||
|
let new_bound: HashSet<_> = visible.iter().copied().collect();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let old_bound = state.continuous_bound.clone();
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
for index in old_bound.difference(&new_bound) {
|
||||||
|
updates.push((state.continuous_rows[*index].picture.clone(), None));
|
||||||
|
}
|
||||||
|
for index in &new_bound {
|
||||||
|
state.access_clock = state.access_clock.wrapping_add(1);
|
||||||
|
let clock = state.access_clock;
|
||||||
|
let texture = state.cache.get_mut(index).map(|page| {
|
||||||
|
page.last_used = clock;
|
||||||
|
page.texture.clone()
|
||||||
|
});
|
||||||
|
updates.push((state.continuous_rows[*index].picture.clone(), texture));
|
||||||
|
}
|
||||||
|
state.continuous_bound = new_bound;
|
||||||
|
let status = visible
|
||||||
|
.first()
|
||||||
|
.map(|index| state.pages[*index].name.clone());
|
||||||
|
drop(state);
|
||||||
|
for (picture, texture) in updates {
|
||||||
|
picture.set_paintable(texture.as_ref());
|
||||||
|
}
|
||||||
|
if let Some(status) = status {
|
||||||
|
self.status_label.set_text(&status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn continuous_scrolled(self: &Rc<Self>) {
|
||||||
|
if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let visible = self.visible_indices();
|
||||||
|
let new_anchor = visible.first().copied();
|
||||||
|
let changed = new_anchor.is_some_and(|index| index != self.state.borrow().anchor);
|
||||||
|
if let Some(index) = new_anchor {
|
||||||
|
self.state.borrow_mut().anchor = index;
|
||||||
|
}
|
||||||
|
self.render_continuous(&visible);
|
||||||
|
self.request_pages(self.prefetch_indices(&visible));
|
||||||
|
self.update_navigation();
|
||||||
|
self.update_spinner();
|
||||||
|
if changed {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let _ = self.database.save_progress(
|
||||||
|
&self.source.source_uri,
|
||||||
|
state.anchor,
|
||||||
|
state.pages.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn continuous_visible_indices(&self) -> Vec<usize> {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
if state.pages.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let adjustment = self.continuous_scrolled.vadjustment();
|
||||||
|
let top = adjustment.value();
|
||||||
|
let bottom = top + adjustment.page_size();
|
||||||
|
if adjustment.page_size() <= 0.0 {
|
||||||
|
return vec![state.anchor];
|
||||||
|
}
|
||||||
|
let bounds: Vec<_> = state
|
||||||
|
.continuous_rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
row.frame
|
||||||
|
.compute_bounds(&self.continuous_box)
|
||||||
|
.map(|bounds| {
|
||||||
|
let row_top = f64::from(bounds.y());
|
||||||
|
(row_top, row_top + f64::from(bounds.height()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut visible = intersecting_rows(&bounds, top, bottom);
|
||||||
|
if visible.is_empty() {
|
||||||
|
visible.push(state.anchor);
|
||||||
|
}
|
||||||
|
visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_scroll_to_anchor(self: &Rc<Self>) {
|
||||||
|
if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
glib::idle_add_local_once(move || {
|
||||||
|
if let Some(controller) = controller.upgrade() {
|
||||||
|
controller.scroll_to_anchor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_to_anchor(&self) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let Some(row) = state.continuous_rows.get(state.anchor) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(bounds) = row.frame.compute_bounds(&self.continuous_box) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let adjustment = self.continuous_scrolled.vadjustment();
|
||||||
|
let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower());
|
||||||
|
adjustment.set_value(f64::from(bounds.y()).clamp(adjustment.lower(), maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_continuous_paintables(&self) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let indices: Vec<_> = state.continuous_bound.drain().collect();
|
||||||
|
let pictures: Vec<_> = indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|index| state.continuous_rows[index].picture.clone())
|
||||||
|
.collect();
|
||||||
|
drop(state);
|
||||||
|
for picture in pictures {
|
||||||
|
picture.set_paintable(gdk::Paintable::NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_continuous_rows(&self) {
|
||||||
|
let width = self.continuous_content_width();
|
||||||
|
let rows: Vec<_> = self
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.continuous_rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| (row.frame.clone(), row.ratio))
|
||||||
|
.collect();
|
||||||
|
for (frame, ratio) in rows {
|
||||||
|
frame.set_size_request(-1, row_height(width, ratio));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn continuous_content_width(&self) -> i32 {
|
||||||
|
(self.continuous_scrolled.allocated_width() - 24).max(320)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_cache(&self) {
|
||||||
|
let protected: HashSet<_> = self.visible_indices().into_iter().collect();
|
||||||
|
let (byte_limit, page_limit) = if self.fast_image_loading {
|
||||||
|
(FAST_CACHE_LIMIT_BYTES, FAST_CACHE_LIMIT_PAGES)
|
||||||
|
} else {
|
||||||
|
(CACHE_LIMIT_BYTES, CACHE_LIMIT_PAGES)
|
||||||
|
};
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
loop {
|
||||||
|
let bytes: usize = state.cache.values().map(|page| page.cost).sum();
|
||||||
|
if bytes <= byte_limit && state.cache.len() <= page_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let candidate = state
|
||||||
|
.cache
|
||||||
|
.iter()
|
||||||
|
.filter(|(index, _)| !protected.contains(index))
|
||||||
|
.min_by_key(|(_, page)| page.last_used)
|
||||||
|
.map(|(index, _)| *index);
|
||||||
|
let Some(candidate) = candidate else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
state.cache.remove(&candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_spinner(&self) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let waiting = self
|
||||||
|
.visible_indices()
|
||||||
|
.iter()
|
||||||
|
.any(|index| !state.cache.contains_key(index) && !state.failed.contains_key(index));
|
||||||
|
if waiting {
|
||||||
|
self.spinner.start();
|
||||||
|
} else {
|
||||||
|
self.spinner.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_navigation(&self) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let count = state.pages.len();
|
||||||
|
if count == 0 {
|
||||||
|
self.page_label.set_text("- / -");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let visible = self.visible_indices();
|
||||||
|
let end = visible.last().copied().unwrap_or(state.anchor) + 1;
|
||||||
|
if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage {
|
||||||
|
self.page_label
|
||||||
|
.set_text(&format!("{}-{end} / {count}", state.anchor + 1));
|
||||||
|
} else {
|
||||||
|
self.page_label
|
||||||
|
.set_text(&format!("{} / {count}", state.anchor + 1));
|
||||||
|
}
|
||||||
|
self.previous_button.set_sensitive(state.anchor > 0);
|
||||||
|
self.next_button.set_sensitive(end < count);
|
||||||
|
self.shift_back_button.set_sensitive(state.anchor > 0);
|
||||||
|
self.shift_forward_button
|
||||||
|
.set_sensitive(state.anchor + 1 < count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_picture() -> gtk::Picture {
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||||
|
picture.set_hexpand(true);
|
||||||
|
picture.set_vexpand(true);
|
||||||
|
picture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spread_indices(anchor: usize, count: usize) -> Vec<usize> {
|
||||||
|
if count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let anchor = anchor.min(count - 1);
|
||||||
|
let mut pages = vec![anchor];
|
||||||
|
if anchor + 1 < count {
|
||||||
|
pages.push(anchor + 1);
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_height(width: i32, ratio: f32) -> i32 {
|
||||||
|
((width as f32 / ratio).round() as i32).clamp(1, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec<usize> {
|
||||||
|
if visible.is_empty() || count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let (ahead, behind) = if fast { (12, 4) } else { (2, 1) };
|
||||||
|
let mut indices = visible.to_vec();
|
||||||
|
let last = *visible.last().unwrap();
|
||||||
|
for offset in 1..=ahead {
|
||||||
|
let index = last + offset;
|
||||||
|
if index >= count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
indices.push(index);
|
||||||
|
}
|
||||||
|
let first = visible[0];
|
||||||
|
for offset in 1..=behind.min(first) {
|
||||||
|
indices.push(first - offset);
|
||||||
|
}
|
||||||
|
indices
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Vec<usize> {
|
||||||
|
bounds
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, bounds)| {
|
||||||
|
bounds
|
||||||
|
.filter(|(row_top, row_bottom)| *row_bottom > top && *row_top < bottom)
|
||||||
|
.map(|_| index)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spread_handles_last_odd_page() {
|
||||||
|
assert_eq!(spread_indices(0, 3), [0, 1]);
|
||||||
|
assert_eq!(spread_indices(2, 3), [2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shifted_pairing_starts_at_requested_page() {
|
||||||
|
assert_eq!(spread_indices(1, 5), [1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_row_height_preserves_image_ratio() {
|
||||||
|
assert_eq!(row_height(1200, 2.0 / 3.0), 1800);
|
||||||
|
assert_eq!(row_height(1200, 1.5), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_visibility_uses_real_unequal_row_bounds() {
|
||||||
|
let bounds = [
|
||||||
|
Some((0.0, 1800.0)),
|
||||||
|
Some((1808.0, 2608.0)),
|
||||||
|
Some((2616.0, 4416.0)),
|
||||||
|
];
|
||||||
|
assert_eq!(intersecting_rows(&bounds, 1000.0, 1900.0), [0, 1]);
|
||||||
|
assert_eq!(intersecting_rows(&bounds, 3000.0, 3800.0), [2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_loading_prefetches_a_larger_window() {
|
||||||
|
assert_eq!(prefetch_window(&[10], 30, false), [10, 11, 12, 9]);
|
||||||
|
assert_eq!(
|
||||||
|
prefetch_window(&[10], 30, true),
|
||||||
|
[
|
||||||
|
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 9, 8, 7, 6
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
822
src/window.rs
Normal file
822
src/window.rs
Normal file
|
|
@ -0,0 +1,822 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use gtk::gio;
|
||||||
|
use gtk::gio::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries};
|
||||||
|
use crate::config::{AppConfig, LocationKind, LocationProfile};
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::source::{
|
||||||
|
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
|
||||||
|
parent_within_root, root_file, validate_profile,
|
||||||
|
};
|
||||||
|
use crate::viewer::ViewerWindow;
|
||||||
|
use crate::zip_cache::ZipCache;
|
||||||
|
|
||||||
|
pub struct ComicWindow {
|
||||||
|
controller: Rc<BrowserController>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComicWindow {
|
||||||
|
pub fn new(
|
||||||
|
application: >k::Application,
|
||||||
|
config: AppConfig,
|
||||||
|
config_path: PathBuf,
|
||||||
|
database: Database,
|
||||||
|
zip_cache: Rc<ZipCache>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let initial_profile = home_profile();
|
||||||
|
let root_uri: String = root_file(&initial_profile)?.uri().into();
|
||||||
|
let window = gtk::ApplicationWindow::builder()
|
||||||
|
.application(application)
|
||||||
|
.title("Comicviewer")
|
||||||
|
.default_width(1100)
|
||||||
|
.default_height(720)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let header = gtk::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(>k::Label::new(Some("Comicviewer"))));
|
||||||
|
window.set_titlebar(Some(&header));
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
|
root.set_margin_top(12);
|
||||||
|
root.set_margin_bottom(12);
|
||||||
|
root.set_margin_start(12);
|
||||||
|
root.set_margin_end(12);
|
||||||
|
|
||||||
|
let locations = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
let location_dropdown = gtk::DropDown::from_strings(&["내 홈"]);
|
||||||
|
location_dropdown.set_hexpand(true);
|
||||||
|
let add_location_button = gtk::Button::with_label("위치 추가");
|
||||||
|
let edit_location_button = gtk::Button::with_label("수정");
|
||||||
|
edit_location_button.set_sensitive(false);
|
||||||
|
let delete_location_button = gtk::Button::with_label("삭제");
|
||||||
|
delete_location_button.set_sensitive(false);
|
||||||
|
locations.append(>k::Label::new(Some("위치")));
|
||||||
|
locations.append(&location_dropdown);
|
||||||
|
locations.append(&add_location_button);
|
||||||
|
locations.append(&edit_location_button);
|
||||||
|
locations.append(&delete_location_button);
|
||||||
|
|
||||||
|
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
let up_button = gtk::Button::builder()
|
||||||
|
.icon_name("go-up-symbolic")
|
||||||
|
.tooltip_text("상위 폴더")
|
||||||
|
.build();
|
||||||
|
let path_entry = gtk::Entry::builder().hexpand(true).build();
|
||||||
|
let open_button = gtk::Button::with_label("이동");
|
||||||
|
let refresh_button = gtk::Button::builder()
|
||||||
|
.icon_name("view-refresh-symbolic")
|
||||||
|
.tooltip_text("새로고침")
|
||||||
|
.build();
|
||||||
|
navigation.append(&up_button);
|
||||||
|
navigation.append(&path_entry);
|
||||||
|
navigation.append(&open_button);
|
||||||
|
navigation.append(&refresh_button);
|
||||||
|
|
||||||
|
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
let search_entry = gtk::SearchEntry::builder()
|
||||||
|
.placeholder_text("현재 폴더에서 검색")
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
|
||||||
|
let direction_button = gtk::ToggleButton::with_label("오름차순");
|
||||||
|
let fast_loading_switch = gtk::Switch::builder()
|
||||||
|
.active(config.fast_image_loading)
|
||||||
|
.tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지")
|
||||||
|
.build();
|
||||||
|
controls.append(&search_entry);
|
||||||
|
controls.append(&sort_dropdown);
|
||||||
|
controls.append(&direction_button);
|
||||||
|
controls.append(>k::Label::new(Some("빠른 이미지 로딩")));
|
||||||
|
controls.append(&fast_loading_switch);
|
||||||
|
|
||||||
|
let list_box = gtk::ListBox::new();
|
||||||
|
list_box.set_selection_mode(gtk::SelectionMode::Single);
|
||||||
|
list_box.set_activate_on_single_click(false);
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&list_box)
|
||||||
|
.build();
|
||||||
|
let status_label = gtk::Label::builder().xalign(0.0).build();
|
||||||
|
|
||||||
|
root.append(&locations);
|
||||||
|
root.append(&navigation);
|
||||||
|
root.append(&controls);
|
||||||
|
root.append(&scrolled);
|
||||||
|
root.append(&status_label);
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
let controller = Rc::new(BrowserController {
|
||||||
|
window,
|
||||||
|
location_dropdown,
|
||||||
|
edit_location_button,
|
||||||
|
delete_location_button,
|
||||||
|
path_entry,
|
||||||
|
search_entry,
|
||||||
|
sort_dropdown,
|
||||||
|
direction_button,
|
||||||
|
list_box,
|
||||||
|
status_label,
|
||||||
|
state: RefCell::new(BrowserState {
|
||||||
|
profile_index: 0,
|
||||||
|
root_uri: root_uri.clone(),
|
||||||
|
current_uri: root_uri,
|
||||||
|
entries: Vec::new(),
|
||||||
|
visible_entries: Vec::new(),
|
||||||
|
sort_field: SortField::Name,
|
||||||
|
sort_direction: SortDirection::Ascending,
|
||||||
|
generation: 0,
|
||||||
|
typeahead_query: String::new(),
|
||||||
|
typeahead_last_input: None,
|
||||||
|
}),
|
||||||
|
config: RefCell::new(config),
|
||||||
|
config_path,
|
||||||
|
database: Rc::new(database),
|
||||||
|
zip_cache,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.rebuild_profiles();
|
||||||
|
controller.connect_signals(
|
||||||
|
open_button,
|
||||||
|
refresh_button,
|
||||||
|
up_button,
|
||||||
|
add_location_button,
|
||||||
|
fast_loading_switch,
|
||||||
|
);
|
||||||
|
controller.refresh();
|
||||||
|
Ok(Self { controller })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn present(&self) {
|
||||||
|
self.controller.window.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowserState {
|
||||||
|
profile_index: usize,
|
||||||
|
root_uri: String,
|
||||||
|
current_uri: String,
|
||||||
|
entries: Vec<BrowserEntry>,
|
||||||
|
visible_entries: Vec<BrowserEntry>,
|
||||||
|
sort_field: SortField,
|
||||||
|
sort_direction: SortDirection,
|
||||||
|
generation: u64,
|
||||||
|
typeahead_query: String,
|
||||||
|
typeahead_last_input: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowserController {
|
||||||
|
window: gtk::ApplicationWindow,
|
||||||
|
location_dropdown: gtk::DropDown,
|
||||||
|
edit_location_button: gtk::Button,
|
||||||
|
delete_location_button: gtk::Button,
|
||||||
|
path_entry: gtk::Entry,
|
||||||
|
search_entry: gtk::SearchEntry,
|
||||||
|
sort_dropdown: gtk::DropDown,
|
||||||
|
direction_button: gtk::ToggleButton,
|
||||||
|
list_box: gtk::ListBox,
|
||||||
|
status_label: gtk::Label,
|
||||||
|
state: RefCell<BrowserState>,
|
||||||
|
config: RefCell<AppConfig>,
|
||||||
|
config_path: PathBuf,
|
||||||
|
database: Rc<Database>,
|
||||||
|
zip_cache: Rc<ZipCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserController {
|
||||||
|
fn connect_signals(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
open_button: gtk::Button,
|
||||||
|
refresh_button: gtk::Button,
|
||||||
|
up_button: gtk::Button,
|
||||||
|
add_location_button: gtk::Button,
|
||||||
|
fast_loading_switch: gtk::Switch,
|
||||||
|
) {
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
open_button.connect_clicked(move |_| controller.open_entered_path());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.path_entry
|
||||||
|
.connect_activate(move |_| controller.open_entered_path());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
refresh_button.connect_clicked(move |_| controller.refresh());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
up_button.connect_clicked(move |_| controller.go_up());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
add_location_button.connect_clicked(move |_| controller.show_location_dialog(None));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.edit_location_button.connect_clicked(move |_| {
|
||||||
|
if let Some(index) = controller.selected_config_index() {
|
||||||
|
controller.show_location_dialog(Some(index));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.delete_location_button.connect_clicked(move |_| {
|
||||||
|
if let Some(index) = controller.selected_config_index() {
|
||||||
|
controller.show_delete_location_dialog(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.location_dropdown
|
||||||
|
.connect_selected_notify(move |_| controller.select_profile());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.search_entry
|
||||||
|
.connect_search_changed(move |_| controller.render());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.sort_dropdown
|
||||||
|
.connect_selected_notify(move |_| controller.change_sort_field());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.direction_button
|
||||||
|
.connect_toggled(move |_| controller.change_sort_direction());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.list_box
|
||||||
|
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
||||||
|
|
||||||
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
key_controller
|
||||||
|
.connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers));
|
||||||
|
self.list_box.add_controller(key_controller);
|
||||||
|
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
fast_loading_switch.connect_active_notify(move |switch| {
|
||||||
|
controller.set_fast_image_loading(switch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profiles(&self) -> Vec<LocationProfile> {
|
||||||
|
let mut profiles = vec![home_profile()];
|
||||||
|
profiles.extend(self.config.borrow().locations.clone());
|
||||||
|
profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_profiles(&self) {
|
||||||
|
let profiles = self.profiles();
|
||||||
|
let names: Vec<_> = profiles
|
||||||
|
.iter()
|
||||||
|
.map(|profile| profile.name.as_str())
|
||||||
|
.collect();
|
||||||
|
self.location_dropdown
|
||||||
|
.set_model(Some(>k::StringList::new(&names)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_profile(self: &Rc<Self>) {
|
||||||
|
let profile_index = self.location_dropdown.selected() as usize;
|
||||||
|
let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len();
|
||||||
|
self.edit_location_button.set_sensitive(editable);
|
||||||
|
self.delete_location_button.set_sensitive(editable);
|
||||||
|
let profiles = self.profiles();
|
||||||
|
let Some(profile) = profiles.get(profile_index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match root_file(profile) {
|
||||||
|
Ok(root) => {
|
||||||
|
let root_uri: String = root.uri().into();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.profile_index = profile_index;
|
||||||
|
state.root_uri = root_uri.clone();
|
||||||
|
state.current_uri = root_uri;
|
||||||
|
drop(state);
|
||||||
|
self.search_entry.set_text("");
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
Err(error) => self.status_label.set_text(&format!("{error:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_entered_path(self: &Rc<Self>) {
|
||||||
|
let profile = self.profiles()[self.state.borrow().profile_index].clone();
|
||||||
|
let entered = self.path_entry.text();
|
||||||
|
let file = file_from_user_input(profile.kind, entered.as_str());
|
||||||
|
let root = gio::File::for_uri(&self.state.borrow().root_uri);
|
||||||
|
if !is_within_root(&file, &root) {
|
||||||
|
self.status_label
|
||||||
|
.set_text("저장된 위치의 루트 아래 경로만 열 수 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.state.borrow_mut().current_uri = file.uri().into();
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn go_up(self: &Rc<Self>) {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let current = gio::File::for_uri(&state.current_uri);
|
||||||
|
let root = gio::File::for_uri(&state.root_uri);
|
||||||
|
drop(state);
|
||||||
|
if let Some(parent) = parent_within_root(¤t, &root) {
|
||||||
|
self.state.borrow_mut().current_uri = parent.uri().into();
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(self: &Rc<Self>) {
|
||||||
|
let (profile, uri, generation) = {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
state.generation = state.generation.wrapping_add(1);
|
||||||
|
state.typeahead_query.clear();
|
||||||
|
state.typeahead_last_input = None;
|
||||||
|
(
|
||||||
|
self.profiles()[state.profile_index].clone(),
|
||||||
|
state.current_uri.clone(),
|
||||||
|
state.generation,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.path_entry.set_text(&display_uri(&uri));
|
||||||
|
self.status_label
|
||||||
|
.set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)");
|
||||||
|
let operation = mount_operation(&profile, &self.window);
|
||||||
|
let progress_controller = Rc::downgrade(self);
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
|
||||||
|
let Some(controller) = progress_controller.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if controller.state.borrow().generation == generation {
|
||||||
|
controller.status_label.set_text(&format!(
|
||||||
|
"폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let Some(controller) = controller.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if controller.state.borrow().generation != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(entries) => {
|
||||||
|
controller.state.borrow_mut().entries = entries;
|
||||||
|
controller.render();
|
||||||
|
}
|
||||||
|
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self) {
|
||||||
|
while let Some(child) = self.list_box.first_child() {
|
||||||
|
self.list_box.remove(&child);
|
||||||
|
}
|
||||||
|
let query = self.search_entry.text().to_lowercase();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
let mut visible_entries: Vec<_> = state
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
sort_entries(&mut visible_entries, state.sort_field, state.sort_direction);
|
||||||
|
for entry in &visible_entries {
|
||||||
|
self.list_box.append(&entry_row(entry));
|
||||||
|
}
|
||||||
|
let count = visible_entries.len();
|
||||||
|
state.visible_entries = visible_entries;
|
||||||
|
self.status_label.set_text(&format!("{count}개 항목"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typeahead(
|
||||||
|
&self,
|
||||||
|
key: gtk::gdk::Key,
|
||||||
|
modifiers: gtk::gdk::ModifierType,
|
||||||
|
) -> glib::Propagation {
|
||||||
|
let blocked = gtk::gdk::ModifierType::CONTROL_MASK
|
||||||
|
| gtk::gdk::ModifierType::ALT_MASK
|
||||||
|
| gtk::gdk::ModifierType::SUPER_MASK;
|
||||||
|
if modifiers.intersects(blocked) {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
let Some(character) = key
|
||||||
|
.to_unicode()
|
||||||
|
.filter(|character| character.is_alphanumeric())
|
||||||
|
else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected = self.list_box.selected_row().map(|row| row.index() as usize);
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state
|
||||||
|
.typeahead_last_input
|
||||||
|
.is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200))
|
||||||
|
{
|
||||||
|
state.typeahead_query.clear();
|
||||||
|
}
|
||||||
|
let input = character.to_lowercase().collect::<String>();
|
||||||
|
let repeated_single =
|
||||||
|
state.typeahead_query.chars().count() == 1 && state.typeahead_query == input;
|
||||||
|
if !repeated_single {
|
||||||
|
state.typeahead_query.push_str(&input);
|
||||||
|
}
|
||||||
|
state.typeahead_last_input = Some(now);
|
||||||
|
let query = state.typeahead_query.clone();
|
||||||
|
let start = if repeated_single {
|
||||||
|
selected.map_or(0, |index| index + 1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let found = find_prefix_index(&state.visible_entries, &query, start);
|
||||||
|
let found_name = found.map(|index| state.visible_entries[index].name.clone());
|
||||||
|
drop(state);
|
||||||
|
|
||||||
|
if let Some(index) = found
|
||||||
|
&& let Some(row) = self.list_box.row_at_index(index as i32)
|
||||||
|
{
|
||||||
|
self.list_box.select_row(Some(&row));
|
||||||
|
let _ = row.grab_focus();
|
||||||
|
self.status_label.set_text(&format!(
|
||||||
|
"'{query}'로 이동: {}",
|
||||||
|
found_name.unwrap_or_default()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
self.status_label
|
||||||
|
.set_text(&format!("'{query}'로 시작하는 항목이 없습니다."));
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_sort_field(&self) {
|
||||||
|
self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() {
|
||||||
|
1 => SortField::Modified,
|
||||||
|
2 => SortField::Created,
|
||||||
|
_ => SortField::Name,
|
||||||
|
};
|
||||||
|
self.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_sort_direction(&self) {
|
||||||
|
let direction = if self.direction_button.is_active() {
|
||||||
|
self.direction_button.set_label("내림차순");
|
||||||
|
SortDirection::Descending
|
||||||
|
} else {
|
||||||
|
self.direction_button.set_label("오름차순");
|
||||||
|
SortDirection::Ascending
|
||||||
|
};
|
||||||
|
self.state.borrow_mut().sort_direction = direction;
|
||||||
|
self.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate_row(self: &Rc<Self>, index: i32) {
|
||||||
|
let Some(entry) = self
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.visible_entries
|
||||||
|
.get(index as usize)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match entry.kind {
|
||||||
|
EntryKind::Directory => {
|
||||||
|
self.state.borrow_mut().current_uri = entry.uri;
|
||||||
|
self.search_entry.set_text("");
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
EntryKind::ZipArchive => self.open_archive(entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_archive(self: &Rc<Self>, entry: BrowserEntry) {
|
||||||
|
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
|
||||||
|
let progress_label = self.status_label.clone();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.zip_cache.prepare(
|
||||||
|
entry,
|
||||||
|
move |current, total| {
|
||||||
|
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||||
|
progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%"));
|
||||||
|
},
|
||||||
|
move |result| match result {
|
||||||
|
Ok(source) => {
|
||||||
|
controller.status_label.set_text("다운로드 완료");
|
||||||
|
let Some(application) = controller.window.application() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ViewerWindow::open(
|
||||||
|
&application,
|
||||||
|
source,
|
||||||
|
Rc::clone(&controller.database),
|
||||||
|
controller.config.borrow().fast_image_loading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fast_image_loading(&self, switch: >k::Switch) {
|
||||||
|
let active = switch.is_active();
|
||||||
|
let previous = self.config.borrow().fast_image_loading;
|
||||||
|
if active == previous {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut updated = self.config.borrow().clone();
|
||||||
|
updated.fast_image_loading = active;
|
||||||
|
match updated.save(&self.config_path) {
|
||||||
|
Ok(()) => {
|
||||||
|
*self.config.borrow_mut() = updated;
|
||||||
|
self.status_label.set_text(if active {
|
||||||
|
"빠른 이미지 로딩을 켰습니다."
|
||||||
|
} else {
|
||||||
|
"빠른 이미지 로딩을 껐습니다."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.status_label
|
||||||
|
.set_text(&format!("설정을 저장할 수 없습니다: {error:#}"));
|
||||||
|
switch.set_active(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_config_index(&self) -> Option<usize> {
|
||||||
|
let index = (self.location_dropdown.selected() as usize).checked_sub(1)?;
|
||||||
|
(index < self.config.borrow().locations.len()).then_some(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_location_dialog(self: &Rc<Self>, edit_index: Option<usize>) {
|
||||||
|
let existing =
|
||||||
|
edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned());
|
||||||
|
let dialog = gtk::Window::builder()
|
||||||
|
.title(if existing.is_some() {
|
||||||
|
"위치 수정"
|
||||||
|
} else {
|
||||||
|
"위치 추가"
|
||||||
|
})
|
||||||
|
.transient_for(&self.window)
|
||||||
|
.modal(true)
|
||||||
|
.default_width(520)
|
||||||
|
.build();
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||||
|
let grid = gtk::Grid::builder()
|
||||||
|
.row_spacing(8)
|
||||||
|
.column_spacing(10)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.build();
|
||||||
|
let kind = gtk::DropDown::from_strings(&["로컬", "SMB", "FTP", "SFTP"]);
|
||||||
|
let name = gtk::Entry::new();
|
||||||
|
let address = gtk::Entry::builder()
|
||||||
|
.placeholder_text("smb://server/share 또는 /home/user/comics")
|
||||||
|
.build();
|
||||||
|
let username = gtk::Entry::new();
|
||||||
|
let password = gtk::Entry::builder().visibility(false).build();
|
||||||
|
let domain = gtk::Entry::new();
|
||||||
|
if let Some(profile) = &existing {
|
||||||
|
kind.set_selected(location_kind_index(profile.kind));
|
||||||
|
name.set_text(&profile.name);
|
||||||
|
address.set_text(&profile.address);
|
||||||
|
username.set_text(&profile.username);
|
||||||
|
password.set_text(&profile.password);
|
||||||
|
domain.set_text(&profile.domain);
|
||||||
|
}
|
||||||
|
for (row, label, widget) in [
|
||||||
|
(0, "종류", kind.clone().upcast::<gtk::Widget>()),
|
||||||
|
(1, "이름", name.clone().upcast()),
|
||||||
|
(2, "주소", address.clone().upcast()),
|
||||||
|
(3, "사용자", username.clone().upcast()),
|
||||||
|
(4, "비밀번호", password.clone().upcast()),
|
||||||
|
(5, "SMB 도메인", domain.clone().upcast()),
|
||||||
|
] {
|
||||||
|
grid.attach(>k::Label::new(Some(label)), 0, row, 1, 1);
|
||||||
|
grid.attach(&widget, 1, row, 1, 1);
|
||||||
|
}
|
||||||
|
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
actions.set_halign(gtk::Align::End);
|
||||||
|
actions.set_margin_bottom(12);
|
||||||
|
actions.set_margin_end(12);
|
||||||
|
let cancel_button = gtk::Button::with_label("취소");
|
||||||
|
let save_button = gtk::Button::with_label("저장");
|
||||||
|
actions.append(&cancel_button);
|
||||||
|
actions.append(&save_button);
|
||||||
|
content.append(&grid);
|
||||||
|
content.append(&actions);
|
||||||
|
dialog.set_child(Some(&content));
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
cancel_button.connect_clicked(move |_| dialog_to_close.close());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
save_button.connect_clicked(move |_| {
|
||||||
|
let profile = LocationProfile {
|
||||||
|
name: name.text().into(),
|
||||||
|
kind: match kind.selected() {
|
||||||
|
1 => LocationKind::Smb,
|
||||||
|
2 => LocationKind::Ftp,
|
||||||
|
3 => LocationKind::Sftp,
|
||||||
|
_ => LocationKind::Local,
|
||||||
|
},
|
||||||
|
address: address.text().into(),
|
||||||
|
username: username.text().into(),
|
||||||
|
password: password.text().into(),
|
||||||
|
domain: domain.text().into(),
|
||||||
|
};
|
||||||
|
if let Err(error) = validate_profile(&profile) {
|
||||||
|
controller.status_label.set_text(&format!("{error:#}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut updated = controller.config.borrow().clone();
|
||||||
|
let selected_profile = if let Some(index) = edit_index {
|
||||||
|
let Some(existing) = updated.locations.get_mut(index) else {
|
||||||
|
controller
|
||||||
|
.status_label
|
||||||
|
.set_text("수정할 위치를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
*existing = profile;
|
||||||
|
index + 1
|
||||||
|
} else {
|
||||||
|
updated.locations.push(profile);
|
||||||
|
updated.locations.len()
|
||||||
|
};
|
||||||
|
if let Err(error) = updated.save(&controller.config_path) {
|
||||||
|
controller.status_label.set_text(&format!("{error:#}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*controller.config.borrow_mut() = updated;
|
||||||
|
controller.rebuild_profiles();
|
||||||
|
controller
|
||||||
|
.location_dropdown
|
||||||
|
.set_selected(selected_profile as u32);
|
||||||
|
dialog_to_close.close();
|
||||||
|
});
|
||||||
|
dialog.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_delete_location_dialog(self: &Rc<Self>, index: usize) {
|
||||||
|
let Some(profile) = self.config.borrow().locations.get(index).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dialog = gtk::Window::builder()
|
||||||
|
.title("위치 삭제")
|
||||||
|
.transient_for(&self.window)
|
||||||
|
.modal(true)
|
||||||
|
.default_width(420)
|
||||||
|
.build();
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||||
|
content.set_margin_top(18);
|
||||||
|
content.set_margin_bottom(12);
|
||||||
|
content.set_margin_start(18);
|
||||||
|
content.set_margin_end(18);
|
||||||
|
let message = gtk::Label::new(Some(&format!(
|
||||||
|
"저장된 위치 '{}'을(를) 삭제하시겠습니까?\n서버의 파일은 삭제되지 않습니다.",
|
||||||
|
profile.name
|
||||||
|
)));
|
||||||
|
message.set_wrap(true);
|
||||||
|
message.set_xalign(0.0);
|
||||||
|
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
actions.set_halign(gtk::Align::End);
|
||||||
|
let cancel_button = gtk::Button::with_label("취소");
|
||||||
|
let delete_button = gtk::Button::with_label("삭제");
|
||||||
|
delete_button.add_css_class("destructive-action");
|
||||||
|
actions.append(&cancel_button);
|
||||||
|
actions.append(&delete_button);
|
||||||
|
content.append(&message);
|
||||||
|
content.append(&actions);
|
||||||
|
dialog.set_child(Some(&content));
|
||||||
|
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
cancel_button.connect_clicked(move |_| dialog_to_close.close());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
delete_button.connect_clicked(move |_| {
|
||||||
|
let mut updated = controller.config.borrow().clone();
|
||||||
|
if index >= updated.locations.len() {
|
||||||
|
controller
|
||||||
|
.status_label
|
||||||
|
.set_text("삭제할 위치를 찾을 수 없습니다.");
|
||||||
|
dialog_to_close.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updated.locations.remove(index);
|
||||||
|
if let Err(error) = updated.save(&controller.config_path) {
|
||||||
|
controller.status_label.set_text(&format!("{error:#}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*controller.config.borrow_mut() = updated;
|
||||||
|
controller.rebuild_profiles();
|
||||||
|
controller.location_dropdown.set_selected(0);
|
||||||
|
controller
|
||||||
|
.status_label
|
||||||
|
.set_text("저장된 위치를 삭제했습니다.");
|
||||||
|
dialog_to_close.close();
|
||||||
|
});
|
||||||
|
dialog.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn location_kind_index(kind: LocationKind) -> u32 {
|
||||||
|
match kind {
|
||||||
|
LocationKind::Local => 0,
|
||||||
|
LocationKind::Smb => 1,
|
||||||
|
LocationKind::Ftp => 2,
|
||||||
|
LocationKind::Sftp => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_profile() -> LocationProfile {
|
||||||
|
LocationProfile {
|
||||||
|
name: "내 홈".into(),
|
||||||
|
kind: LocationKind::Local,
|
||||||
|
address: std::env::var("HOME").unwrap_or_else(|_| "/".into()),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
domain: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_uri(uri: &str) -> String {
|
||||||
|
let file = gio::File::for_uri(uri);
|
||||||
|
file.path().map_or_else(
|
||||||
|
|| display_remote_uri(file.uri().as_str()),
|
||||||
|
|path| path.to_string_lossy().into_owned(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
|
||||||
|
let row = gtk::ListBoxRow::new();
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||||
|
content.set_margin_top(8);
|
||||||
|
content.set_margin_bottom(8);
|
||||||
|
content.set_margin_start(10);
|
||||||
|
content.set_margin_end(10);
|
||||||
|
let icon = match entry.kind {
|
||||||
|
EntryKind::Directory => "folder-symbolic",
|
||||||
|
EntryKind::ZipArchive => "package-x-generic-symbolic",
|
||||||
|
};
|
||||||
|
content.append(>k::Image::from_icon_name(icon));
|
||||||
|
content.append(
|
||||||
|
>k::Label::builder()
|
||||||
|
.label(&entry.name)
|
||||||
|
.hexpand(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.ellipsize(gtk::pango::EllipsizeMode::Middle)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
for time in [entry.modified, entry.created] {
|
||||||
|
let label = gtk::Label::new(Some(&format_time(time)));
|
||||||
|
label.set_width_chars(17);
|
||||||
|
label.set_xalign(1.0);
|
||||||
|
content.append(&label);
|
||||||
|
}
|
||||||
|
row.set_child(Some(&content));
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(time: Option<std::time::SystemTime>) -> String {
|
||||||
|
time.map(|time| {
|
||||||
|
let datetime: DateTime<Local> = time.into();
|
||||||
|
datetime.format("%Y-%m-%d %H:%M").to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "-".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Option<usize> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
(0..entries.len())
|
||||||
|
.map(|offset| (start + offset) % entries.len())
|
||||||
|
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(name: &str) -> BrowserEntry {
|
||||||
|
BrowserEntry {
|
||||||
|
uri: format!("file:///{name}"),
|
||||||
|
name: name.into(),
|
||||||
|
kind: EntryKind::Directory,
|
||||||
|
size: None,
|
||||||
|
modified: None,
|
||||||
|
created: None,
|
||||||
|
etag: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typeahead_matches_case_insensitively() {
|
||||||
|
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
|
||||||
|
assert_eq!(find_prefix_index(&entries, "br", 0), Some(2));
|
||||||
|
assert_eq!(find_prefix_index(&entries, "AL", 0), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_typeahead_wraps_to_next_match() {
|
||||||
|
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
|
||||||
|
assert_eq!(find_prefix_index(&entries, "b", 2), Some(2));
|
||||||
|
assert_eq!(find_prefix_index(&entries, "b", 3), Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/zip_cache.rs
Normal file
320
src/zip_cache.rs
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::rc::{Rc, Weak};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use filetime::FileTime;
|
||||||
|
use gtk::gio;
|
||||||
|
use gtk::gio::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::browser::BrowserEntry;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ArchiveSource {
|
||||||
|
pub local_path: PathBuf,
|
||||||
|
pub source_uri: String,
|
||||||
|
_temporary: Option<Rc<tempfile::TempPath>>,
|
||||||
|
_pin: Option<Rc<CachePin>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ZipCache {
|
||||||
|
directory: PathBuf,
|
||||||
|
quota_bytes: u64,
|
||||||
|
pinned: RefCell<HashMap<PathBuf, usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachePin {
|
||||||
|
path: PathBuf,
|
||||||
|
cache: Weak<ZipCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CachePin {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(cache) = self.cache.upgrade() {
|
||||||
|
let mut pinned = cache.pinned.borrow_mut();
|
||||||
|
if let Some(count) = pinned.get_mut(&self.path) {
|
||||||
|
*count -= 1;
|
||||||
|
if *count == 0 {
|
||||||
|
pinned.remove(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZipCache {
|
||||||
|
pub fn new(directory: PathBuf, quota_gb: u64) -> Result<Rc<Self>> {
|
||||||
|
fs::create_dir_all(&directory)?;
|
||||||
|
for entry in fs::read_dir(&directory)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
if path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|extension| extension == "part")
|
||||||
|
{
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Rc::new(Self {
|
||||||
|
directory,
|
||||||
|
quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
|
||||||
|
pinned: RefCell::new(HashMap::new()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(Result<ArchiveSource>) + 'static,
|
||||||
|
P: FnMut(i64, i64) + 'static,
|
||||||
|
{
|
||||||
|
let source = gio::File::for_uri(&entry.uri);
|
||||||
|
if let Some(path) = native_path(&source) {
|
||||||
|
callback(Ok(ArchiveSource {
|
||||||
|
local_path: path,
|
||||||
|
source_uri: entry.uri,
|
||||||
|
_temporary: None,
|
||||||
|
_pin: None,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
|
||||||
|
self.download_temporary(source, entry.uri, entry.size, progress, callback);
|
||||||
|
} else {
|
||||||
|
self.download_persistent(source, entry, progress, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_temporary<F, P>(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
source: gio::File,
|
||||||
|
source_uri: String,
|
||||||
|
expected_size: Option<u64>,
|
||||||
|
progress: P,
|
||||||
|
callback: F,
|
||||||
|
) where
|
||||||
|
F: FnOnce(Result<ArchiveSource>) + 'static,
|
||||||
|
P: FnMut(i64, i64) + 'static,
|
||||||
|
{
|
||||||
|
let temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() {
|
||||||
|
Ok(file) => Rc::new(file.into_temp_path()),
|
||||||
|
Err(error) => {
|
||||||
|
callback(Err(error.into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let destination = gio::File::for_path(temporary.as_ref());
|
||||||
|
let retained = Rc::clone(&temporary);
|
||||||
|
source.copy_async(
|
||||||
|
&destination,
|
||||||
|
gio::FileCopyFlags::OVERWRITE,
|
||||||
|
glib::Priority::DEFAULT,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
Some(Box::new(progress)),
|
||||||
|
move |result| {
|
||||||
|
callback(result.map_err(Into::into).and_then(|()| {
|
||||||
|
validate_download(retained.as_ref(), expected_size)?;
|
||||||
|
Ok(ArchiveSource {
|
||||||
|
local_path: retained.to_path_buf(),
|
||||||
|
source_uri,
|
||||||
|
_temporary: Some(retained),
|
||||||
|
_pin: None,
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_persistent<F, P>(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
source: gio::File,
|
||||||
|
entry: BrowserEntry,
|
||||||
|
progress: P,
|
||||||
|
callback: F,
|
||||||
|
) where
|
||||||
|
F: FnOnce(Result<ArchiveSource>) + 'static,
|
||||||
|
P: FnMut(i64, i64) + 'static,
|
||||||
|
{
|
||||||
|
let key = cache_key(&entry);
|
||||||
|
let final_path = self.directory.join(format!("{key}.zip"));
|
||||||
|
if final_path.is_file() {
|
||||||
|
if validate_download(&final_path, entry.size).is_ok() {
|
||||||
|
let _ = filetime::set_file_mtime(&final_path, FileTime::now());
|
||||||
|
callback(Ok(self.lease(final_path, entry.uri)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&final_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let part_path = self.directory.join(format!("{key}.part"));
|
||||||
|
let destination = gio::File::for_path(&part_path);
|
||||||
|
let cache = Rc::clone(self);
|
||||||
|
source.copy_async(
|
||||||
|
&destination,
|
||||||
|
gio::FileCopyFlags::OVERWRITE,
|
||||||
|
glib::Priority::DEFAULT,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
Some(Box::new(progress)),
|
||||||
|
move |result| {
|
||||||
|
let result = result.map_err(Into::into).and_then(|()| {
|
||||||
|
validate_download(&part_path, entry.size)?;
|
||||||
|
fs::rename(&part_path, &final_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"다운로드 파일을 완료 처리할 수 없습니다: {}",
|
||||||
|
final_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
cache.enforce_quota(&final_path)?;
|
||||||
|
Ok(cache.lease(final_path, entry.uri))
|
||||||
|
});
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&part_path);
|
||||||
|
}
|
||||||
|
callback(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce_quota(&self, protected: &Path) -> Result<()> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut total = 0_u64;
|
||||||
|
for entry in fs::read_dir(&self.directory)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
if path.extension().is_none_or(|extension| extension != "zip") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = fs::metadata(&path)?;
|
||||||
|
total = total.saturating_add(metadata.len());
|
||||||
|
files.push((metadata.modified().ok(), metadata.len(), path));
|
||||||
|
}
|
||||||
|
files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
|
||||||
|
for (_, size, path) in files {
|
||||||
|
if total <= self.quota_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if path != protected
|
||||||
|
&& !self.pinned.borrow().contains_key(&path)
|
||||||
|
&& fs::remove_file(&path).is_ok()
|
||||||
|
{
|
||||||
|
total = total.saturating_sub(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lease(self: &Rc<Self>, path: PathBuf, source_uri: String) -> ArchiveSource {
|
||||||
|
*self.pinned.borrow_mut().entry(path.clone()).or_default() += 1;
|
||||||
|
ArchiveSource {
|
||||||
|
local_path: path.clone(),
|
||||||
|
source_uri,
|
||||||
|
_temporary: None,
|
||||||
|
_pin: Some(Rc::new(CachePin {
|
||||||
|
path,
|
||||||
|
cache: Rc::downgrade(self),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_path(file: &gio::File) -> Option<PathBuf> {
|
||||||
|
file.is_native().then(|| file.path()).flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_download(path: &Path, expected_size: Option<u64>) -> Result<()> {
|
||||||
|
let actual_size = fs::metadata(path)?.len();
|
||||||
|
if let Some(expected_size) = expected_size
|
||||||
|
&& actual_size != expected_size
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let file = File::open(path)?;
|
||||||
|
zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_key(entry: &BrowserEntry) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(entry.uri.as_bytes());
|
||||||
|
hasher.update(entry.size.unwrap_or_default().to_le_bytes());
|
||||||
|
hasher.update(
|
||||||
|
entry
|
||||||
|
.modified
|
||||||
|
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map_or(0, |duration| duration.as_secs())
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
if let Some(etag) = &entry.etag {
|
||||||
|
hasher.update(etag.as_bytes());
|
||||||
|
}
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::browser::EntryKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_key_changes_with_remote_metadata() {
|
||||||
|
let mut entry = BrowserEntry {
|
||||||
|
uri: "sftp://example.invalid/a.zip".into(),
|
||||||
|
name: "a.zip".into(),
|
||||||
|
kind: EntryKind::ZipArchive,
|
||||||
|
size: Some(10),
|
||||||
|
modified: None,
|
||||||
|
created: None,
|
||||||
|
etag: None,
|
||||||
|
};
|
||||||
|
let first = cache_key(&entry);
|
||||||
|
entry.size = Some(11);
|
||||||
|
assert_ne!(first, cache_key(&entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_gio_files_are_not_opened_as_local_archives() {
|
||||||
|
let remote = gio::File::for_uri("ftp://example.invalid/comic.zip");
|
||||||
|
let local = gio::File::for_path("/tmp/comic.zip");
|
||||||
|
|
||||||
|
assert!(native_path(&remote).is_none());
|
||||||
|
assert_eq!(native_path(&local), Some(PathBuf::from("/tmp/comic.zip")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_completed_download_is_rejected() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("invalid.zip");
|
||||||
|
fs::write(&path, b"not a zip").unwrap();
|
||||||
|
|
||||||
|
assert!(validate_download(&path, Some(9)).is_err());
|
||||||
|
assert!(validate_download(&path, Some(1)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lru_does_not_remove_an_open_archive() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let first = directory.path().join("first.zip");
|
||||||
|
let second = directory.path().join("second.zip");
|
||||||
|
fs::write(&first, [0_u8; 8]).unwrap();
|
||||||
|
fs::write(&second, [0_u8; 8]).unwrap();
|
||||||
|
let cache = Rc::new(ZipCache {
|
||||||
|
directory: directory.path().to_path_buf(),
|
||||||
|
quota_bytes: 8,
|
||||||
|
pinned: RefCell::new(HashMap::new()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let lease = cache.lease(first.clone(), "file:///first.zip".into());
|
||||||
|
cache.enforce_quota(&second).unwrap();
|
||||||
|
assert!(first.exists());
|
||||||
|
assert!(second.exists());
|
||||||
|
|
||||||
|
drop(lease);
|
||||||
|
cache.enforce_quota(&second).unwrap();
|
||||||
|
assert!(!first.exists());
|
||||||
|
assert!(second.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue