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

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());
}
}