Initial Comicviewer implementation

This commit is contained in:
burnintuna 2026-07-21 02:12:05 +09:00
commit cb01395612
20 changed files with 4940 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target/
/AppDir/
/.appimage-tools/
*.AppImage
*.AppImage.zsync

86
AGENTS.md Normal file
View 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

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View 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
View 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.

View 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

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: &gtk::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
View 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
View 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: &gtk::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: &gtk::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
View 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: &gtk::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(&gtk::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
View 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: &gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&current, &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: &gtk::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(&gtk::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(&gtk::Image::from_icon_name(icon));
content.append(
&gtk::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
View 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());
}
}