Release v0.2.1

This commit is contained in:
burnintuna 2026-07-22 14:42:23 +09:00
parent ffa8bc93ba
commit 048df6355e
10 changed files with 1467 additions and 254 deletions

2
Cargo.lock generated
View file

@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "comicviewer"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "comicviewer"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later"

View file

@ -26,6 +26,7 @@
</provides>
<content_rating type="oars-1.1" />
<releases>
<release version="0.2.1" date="2026-07-22" />
<release version="0.2.0" date="2026-07-21" />
<release version="0.1.4" date="2026-07-21" />
<release version="0.1.3" date="2026-07-21" />

View file

@ -79,4 +79,18 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다.
- FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다.
- 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다.
- 기본 영구 ZIP 캐시 한도는 10GB이며 설정에서 변경할 수 있습니다.
- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다.
- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
## 뷰어 조작
- `[`와 `]`: 폴더의 이전/다음 ZIP·이미지·동영상 열기
- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.

View file

@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256`
- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256`
- `dist/Comicviewer-latest-x86_64.tar.gz`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
```sh
git add Cargo.toml Cargo.lock data packaging README.md docs
git commit -m "Prepare v0.2.0 release"
git tag -a v0.2.0 -m "Comicviewer v0.2.0"
git commit -m "Prepare v0.2.1 release"
git tag -a v0.2.1 -m "Comicviewer v0.2.1"
git push origin main
git push origin v0.2.0
git push origin v0.2.1
```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새

View file

@ -1,9 +1,13 @@
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use image::ImageReader;
use image::codecs::gif::GifDecoder;
use image::codecs::webp::WebPDecoder;
use image::metadata::LoopCount;
use image::{AnimationDecoder, ImageFormat, ImageReader};
use zip::ZipArchive;
use crate::browser::natural_cmp_with_case;
@ -11,6 +15,9 @@ use crate::browser::natural_cmp_with_case;
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 30_000;
const MAX_IMAGE_PIXELS: u64 = 100_000_000;
const MAX_ANIMATION_FRAMES: usize = 1_000;
const MAX_ANIMATION_BYTES: usize = 512 * 1024 * 1024;
const MIN_FRAME_DELAY: Duration = Duration::from_millis(10);
#[derive(Debug, Clone)]
pub struct ArchivePage {
@ -21,7 +28,13 @@ pub struct ArchivePage {
pub struct DecodedPage {
pub width: u32,
pub height: u32,
pub frames: Vec<DecodedFrame>,
pub loop_count: Option<u32>,
}
pub struct DecodedFrame {
pub rgba: Vec<u8>,
pub delay: Duration,
}
pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage>> {
@ -90,11 +103,33 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format()
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
let format = reader.format();
let (width, height) = reader
.into_dimensions()
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
validate_dimensions(width, height, name)?;
let animated = match format {
Some(ImageFormat::Gif) => {
let decoder = GifDecoder::new(Cursor::new(encoded))
.with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?;
let loop_count = animation_loop_count(decoder.loop_count());
Some((decoder.into_frames(), loop_count))
}
Some(ImageFormat::WebP) => {
let decoder = WebPDecoder::new(Cursor::new(encoded))
.with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?;
decoder.has_animation().then(|| {
let loop_count = animation_loop_count(decoder.loop_count());
(decoder.into_frames(), loop_count)
})
}
_ => None,
};
if let Some((frames, loop_count)) = animated {
return decoded_animation(width, height, frames, loop_count, name);
}
let image = image::load_from_memory(encoded)
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
let rgba = image.into_rgba8();
@ -102,10 +137,69 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
Ok(DecodedPage {
width,
height,
rgba: rgba.into_raw(),
frames: vec![DecodedFrame {
rgba: rgba.into_raw(),
delay: Duration::ZERO,
}],
loop_count: Some(1),
})
}
fn decoded_animation(
width: u32,
height: u32,
frames: impl Iterator<Item = image::ImageResult<image::Frame>>,
loop_count: Option<u32>,
name: &str,
) -> Result<DecodedPage> {
let expected_bytes = usize::try_from(width)?
.checked_mul(usize::try_from(height)?)
.and_then(|pixels| pixels.checked_mul(4))
.context("애니메이션 프레임 크기를 처리할 수 없습니다")?;
let mut decoded = Vec::new();
let mut total_bytes = 0_usize;
for (index, frame) in frames.enumerate() {
if index >= MAX_ANIMATION_FRAMES {
bail!(
"애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)"
);
}
let frame =
frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?;
total_bytes = total_bytes
.checked_add(expected_bytes)
.context("애니메이션 전체 크기를 처리할 수 없습니다")?;
if total_bytes > MAX_ANIMATION_BYTES {
bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)");
}
let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY);
let rgba = frame.into_buffer();
if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes {
bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}");
}
decoded.push(DecodedFrame {
rgba: rgba.into_raw(),
delay,
});
}
if decoded.is_empty() {
bail!("애니메이션에 표시할 프레임이 없습니다: {name}");
}
Ok(DecodedPage {
width,
height,
frames: decoded,
loop_count,
})
}
fn animation_loop_count(loop_count: LoopCount) -> Option<u32> {
match loop_count {
LoopCount::Infinite => None,
LoopCount::Finite(count) => Some(count.get()),
}
}
fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> {
let pixels = u64::from(width) * u64::from(height);
if width == 0
@ -188,7 +282,7 @@ mod tests {
);
let decoded = decode_page(&path, &pages[0]).unwrap();
assert_eq!((decoded.width, decoded.height), (2, 3));
assert_eq!(decoded.rgba.len(), 2 * 3 * 4);
assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4);
std::fs::remove_file(path).unwrap();
}
@ -202,6 +296,37 @@ mod tests {
let decoded = decode_image_file(&path, "photo.png").unwrap();
assert_eq!((decoded.width, decoded.height), (4, 5));
assert_eq!(decoded.rgba.len(), 4 * 5 * 4);
assert_eq!(decoded.frames[0].rgba.len(), 4 * 5 * 4);
}
#[test]
fn decodes_all_animated_gif_frames() {
let mut encoded = Vec::new();
let first = image::Frame::from_parts(
image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])),
0,
0,
image::Delay::from_numer_denom_ms(20, 1),
);
let second = image::Frame::from_parts(
image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])),
0,
0,
image::Delay::from_numer_denom_ms(30, 1),
);
let mut encoder = image::codecs::gif::GifEncoder::new(&mut encoded);
encoder
.set_repeat(image::codecs::gif::Repeat::Finite(2))
.unwrap();
encoder.encode_frames([first, second]).unwrap();
drop(encoder);
let decoded = decode_image_bytes(&encoded, "animated.gif").unwrap();
assert_eq!(decoded.frames.len(), 2);
assert_eq!(decoded.frames[0].delay, Duration::from_millis(20));
assert_eq!(decoded.frames[1].delay, Duration::from_millis(30));
assert_eq!(decoded.loop_count, Some(2));
assert_ne!(decoded.frames[0].rgba, decoded.frames[1].rgba);
}
}

View file

@ -15,10 +15,6 @@ impl EntryKind {
pub fn is_viewable(self) -> bool {
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
}
pub fn is_media(self) -> bool {
matches!(self, Self::Image | Self::Video)
}
}
#[derive(Debug, Clone)]
@ -229,10 +225,6 @@ mod tests {
assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video);
assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video);
assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile);
assert!(EntryKind::Image.is_media());
assert!(EntryKind::Video.is_media());
assert!(!EntryKind::ZipArchive.is_media());
assert!(!EntryKind::OtherFile.is_media());
}
#[test]

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,9 @@ use crate::source::{
mount_operation, parent_within_root, root_file, validate_profile,
};
use crate::viewer::{
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
SiblingTarget, ViewModeControl, ViewerWindow,
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
set_viewer_header,
};
use crate::zip_cache::ZipCache;
@ -51,6 +52,8 @@ impl ComicWindow {
header.set_title_widget(Some(&gtk::Label::new(Some("Comicviewer"))));
let about_button = gtk::Button::with_label("정보");
header.pack_end(&about_button);
let clear_cache_button = gtk::Button::with_label("캐시 비우기");
header.pack_end(&clear_cache_button);
window.set_titlebar(Some(&header));
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
@ -128,6 +131,7 @@ impl ComicWindow {
let controller = Rc::new(BrowserController {
window,
about_button,
clear_cache_button,
location_dropdown,
edit_location_button,
delete_location_button,
@ -155,6 +159,7 @@ impl ComicWindow {
config_path,
database: Rc::new(database),
zip_cache,
listing_task: RefCell::new(None),
});
controller.rebuild_profiles();
@ -193,6 +198,7 @@ struct BrowserState {
struct BrowserController {
window: gtk::ApplicationWindow,
about_button: gtk::Button,
clear_cache_button: gtk::Button,
location_dropdown: gtk::DropDown,
edit_location_button: gtk::Button,
delete_location_button: gtk::Button,
@ -207,6 +213,7 @@ struct BrowserController {
config_path: PathBuf,
database: Rc<Database>,
zip_cache: Rc<ZipCache>,
listing_task: RefCell<Option<glib::JoinHandle<()>>>,
}
impl BrowserController {
@ -326,6 +333,9 @@ impl BrowserController {
fast_loading_switch.connect_active_notify(move |switch| {
controller.set_fast_image_loading(switch);
});
let controller = Rc::clone(self);
self.clear_cache_button
.connect_clicked(move |_| controller.show_clear_cache_dialog());
}
fn profiles(&self) -> Vec<LocationProfile> {
@ -388,6 +398,7 @@ impl BrowserController {
}
fn show_favorites(&self) {
self.cancel_listing();
self.edit_location_button.set_sensitive(false);
self.delete_location_button.set_sensitive(false);
self.path_entry.set_text("즐겨찾기");
@ -439,12 +450,29 @@ impl BrowserController {
return;
}
self.cancel_listing();
self.status_label.set_text("즐겨찾기에 연결하는 중...");
let operation = mount_operation(&favorite.profile, &self.window);
let controller = Rc::downgrade(self);
glib::MainContext::default().spawn_local(async move {
let task = glib::MainContext::default().spawn_local(async move {
let file = gio::File::for_uri(&favorite.uri);
let result = ensure_mounted(&file, &favorite.profile, &operation).await;
let folder_entries = if result.is_ok() {
if let Some(parent) = file.parent() {
enumerate_directory(
&favorite.profile,
parent.uri().as_str(),
&operation,
|_| {},
)
.await
.ok()
} else {
None
}
} else {
None
};
let Some(controller) = controller.upgrade() else {
return;
};
@ -459,18 +487,36 @@ impl BrowserController {
.set_text("이 즐겨찾기 파일 형식은 열 수 없습니다.");
return;
}
let entry = BrowserEntry {
uri: favorite.uri,
name: favorite.name,
kind,
size: None,
modified: None,
created: None,
etag: None,
};
let siblings = Rc::new(vec![entry.clone()]);
let entry = folder_entries
.as_ref()
.and_then(|entries| entries.iter().find(|entry| entry.uri == favorite.uri))
.cloned()
.unwrap_or_else(|| BrowserEntry {
uri: favorite.uri.clone(),
name: favorite.name.clone(),
kind,
size: None,
modified: None,
created: None,
etag: None,
});
let siblings = folder_entries.map_or_else(
|| vec![entry.clone()],
|entries| {
let state = controller.state.borrow();
sorted_navigable_entries(
&entries,
kind,
state.sort_field,
state.sort_direction,
controller.config.borrow().case_insensitive_sort,
)
},
);
let siblings = Rc::new(siblings);
controller.open_viewable(entry, siblings, favorite.profile, None);
});
self.listing_task.replace(Some(task));
}
fn open_entered_path(self: &Rc<Self>) {
@ -512,6 +558,7 @@ impl BrowserController {
self.show_favorites();
return;
}
self.cancel_listing();
let (profile, uri, generation) = {
let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1);
@ -532,7 +579,7 @@ impl BrowserController {
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 task = glib::MainContext::default().spawn_local(async move {
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
let Some(controller) = progress_controller.upgrade() else {
return;
@ -561,6 +608,13 @@ impl BrowserController {
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
}
});
self.listing_task.replace(Some(task));
}
fn cancel_listing(&self) {
if let Some(task) = self.listing_task.borrow_mut().take() {
task.abort();
}
}
fn render(&self) {
@ -733,19 +787,7 @@ impl BrowserController {
match entry.kind {
EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
EntryKind::Image | EntryKind::Video => {
let media_siblings = Rc::new(
siblings
.iter()
.filter(|sibling| sibling.kind.is_media())
.cloned()
.collect::<Vec<_>>(),
);
if media_siblings
.iter()
.any(|sibling| sibling.uri == entry.uri)
{
self.open_media(entry, media_siblings, profile, reuse_window);
}
self.open_media(entry, siblings, profile, reuse_window)
}
_ => {}
}
@ -773,14 +815,22 @@ impl BrowserController {
});
let controller = Rc::downgrade(self);
let navigate_siblings = Rc::clone(&siblings);
let navigate_current_uri = current_uri.clone();
let navigate_profile = profile.clone();
let navigate = Rc::new(
move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| {
let Some(controller) = controller.upgrade() else {
return false;
};
let siblings =
controller.navigation_siblings(&current_uri, current_kind, &siblings);
let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri)
let siblings = controller.navigation_siblings(
&navigate_current_uri,
current_kind,
&navigate_siblings,
);
let Some(current) = siblings
.iter()
.position(|entry| entry.uri == navigate_current_uri)
else {
return false;
};
@ -790,13 +840,47 @@ impl BrowserController {
controller.open_viewable(
siblings[next].clone(),
Rc::clone(&siblings),
profile.clone(),
navigate_profile.clone(),
Some(reuse_window),
);
true
},
);
SiblingNavigator::new(position, navigate)
let files_controller = Rc::downgrade(self);
let files_current_uri = current_uri.clone();
let files_fallback = Rc::clone(&siblings);
let files = Rc::new(move || {
let Some(controller) = files_controller.upgrade() else {
return Vec::new();
};
controller
.navigation_siblings(&files_current_uri, current_kind, &files_fallback)
.iter()
.map(|entry| SiblingFile {
uri: entry.uri.clone(),
name: entry.name.clone(),
kind: entry.kind,
})
.collect()
});
let actions_controller = Rc::downgrade(self);
let actions_current_uri = current_uri;
let actions_fallback = Rc::clone(&siblings);
let actions_profile = profile;
let actions = Rc::new(move |uri: &str| {
let controller = actions_controller.upgrade()?;
let entries = controller.navigation_siblings(
&actions_current_uri,
current_kind,
&actions_fallback,
);
let entry = entries.iter().find(|entry| entry.uri == uri)?;
Some(SiblingFileActions {
favorite: controller.favorite_control(entry, &actions_profile),
download: controller.download_control(entry),
})
});
SiblingNavigator::new(position, navigate, files, actions)
}
fn navigation_siblings(
@ -814,8 +898,9 @@ impl BrowserController {
state.sort_direction,
case_insensitive,
);
let browsing_favorites = state.browsing_favorites;
drop(state);
if entries.iter().any(|entry| entry.uri == current_uri) {
if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) {
Rc::new(entries)
} else {
Rc::clone(fallback)
@ -846,33 +931,41 @@ impl BrowserController {
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("다운로드 완료");
result_label.set_text("ZIP 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
ViewerWindow::open(
&application,
source,
Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading,
controller.config.borrow().case_insensitive_sort,
view_mode_control,
FileViewerControls {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
reuse_window,
view_mode_control: None,
},
);
move |result| {
if reuse_window
.as_ref()
.is_some_and(|window| !window.is_visible())
{
return;
}
Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
result_label.set_text("ZIP 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
ViewerWindow::open(
&application,
source,
Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading,
controller.config.borrow().case_insensitive_sort,
view_mode_control,
FileViewerControls {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
reuse_window,
view_mode_control: None,
},
);
}
Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
}
}
},
);
@ -904,30 +997,38 @@ impl BrowserController {
let percent = if total > 0 { current * 100 / total } else { 0 };
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
},
move |result| match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
result_label.set_text("미디어 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
MediaViewerWindow::open(
&application,
source,
kind,
FileViewerControls {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
reuse_window,
view_mode_control,
},
);
move |result| {
if reuse_window
.as_ref()
.is_some_and(|window| !window.is_visible())
{
return;
}
Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
result_label.set_text("미디어 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
MediaViewerWindow::open(
&application,
source,
kind,
FileViewerControls {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
reuse_window,
view_mode_control,
},
);
}
Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
}
}
},
);
@ -1252,6 +1353,60 @@ impl BrowserController {
dialog.present();
}
fn show_clear_cache_dialog(self: &Rc<Self>) {
let dialog = gtk::Window::builder()
.title("캐시 비우기")
.transient_for(&self.window)
.modal(true)
.resizable(false)
.default_width(440)
.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(
"다운로드한 ZIP 캐시를 삭제하시겠습니까?\n현재 열려 있는 ZIP과 읽기 기록은 유지됩니다.",
));
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 clear_button = gtk::Button::with_label("캐시 삭제");
clear_button.add_css_class("destructive-action");
actions.append(&cancel_button);
actions.append(&clear_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();
clear_button.connect_clicked(move |_| match controller.zip_cache.clear_persistent() {
Ok(report) => {
let removed_mib = report.removed_bytes as f64 / (1024.0 * 1024.0);
let retained = if report.retained_open_files == 0 {
String::new()
} else {
format!(", 열려 있어 유지한 파일 {}개", report.retained_open_files)
};
controller.status_label.set_text(&format!(
"ZIP 캐시 {}개({removed_mib:.1} MiB)를 삭제했습니다{retained}.",
report.removed_files
));
dialog_to_close.close();
}
Err(error) => controller
.status_label
.set_text(&format!("캐시를 삭제할 수 없습니다: {error:#}")),
});
dialog.present();
}
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());
@ -1504,11 +1659,7 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt
}
fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
match current {
EntryKind::ZipArchive => candidate.is_viewable(),
EntryKind::Image | EntryKind::Video => candidate.is_media(),
_ => false,
}
current.is_viewable() && candidate.is_viewable()
}
fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
@ -1555,7 +1706,7 @@ fn show_viewer_loading(
window.set_title(Some(file_name));
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(file_name))));
window.set_titlebar(Some(&header));
set_viewer_header(window, &header);
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
content.set_halign(gtk::Align::Center);
@ -1645,11 +1796,13 @@ mod tests {
}
#[test]
fn media_navigation_excludes_zip_archives() {
fn viewer_navigation_includes_all_viewable_files() {
assert!(navigable_sibling(EntryKind::Image, EntryKind::Video));
assert!(navigable_sibling(EntryKind::Video, EntryKind::Image));
assert!(!navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
assert!(navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
assert!(navigable_sibling(EntryKind::ZipArchive, EntryKind::Video));
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
assert!(!navigable_sibling(EntryKind::Directory, EntryKind::Image));
}
#[test]

View file

@ -1,4 +1,4 @@
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
@ -24,10 +24,18 @@ pub struct ArchiveSource {
pub struct ZipCache {
directory: PathBuf,
quota_bytes: u64,
quota_bytes: Cell<u64>,
pinned: RefCell<HashMap<PathBuf, usize>>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct CacheCleanupReport {
pub removed_files: usize,
pub removed_bytes: u64,
pub retained_open_files: usize,
pub remaining_bytes: u64,
}
struct CachePin {
path: PathBuf,
cache: Weak<ZipCache>,
@ -37,12 +45,18 @@ impl Drop for CachePin {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut pinned = cache.pinned.borrow_mut();
let mut released = false;
if let Some(count) = pinned.get_mut(&self.path) {
*count -= 1;
if *count == 0 {
pinned.remove(&self.path);
released = true;
}
}
drop(pinned);
if released {
let _ = cache.enforce_quota(None);
}
}
}
}
@ -52,18 +66,24 @@ impl ZipCache {
fs::create_dir_all(&directory)?;
for entry in fs::read_dir(&directory)? {
let path = entry?.path();
if path
let incomplete_download = path
.extension()
.is_some_and(|extension| extension == "part")
{
.is_some_and(|extension| extension == "part");
let stale_temporary = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"));
if incomplete_download || stale_temporary {
let _ = fs::remove_file(path);
}
}
Ok(Rc::new(Self {
let cache = Rc::new(Self {
directory,
quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
quota_bytes: Cell::new(quota_gb.saturating_mul(1024 * 1024 * 1024)),
pinned: RefCell::new(HashMap::new()),
}))
});
cache.enforce_quota(None)?;
Ok(cache)
}
pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
@ -83,7 +103,8 @@ impl ZipCache {
return;
}
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
let quota_bytes = self.quota_bytes.get();
if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) {
self.download_temporary(source, entry, true, progress, callback);
} else {
self.download_persistent(source, entry, progress, callback);
@ -127,7 +148,11 @@ impl ZipCache {
|| ".part".to_owned(),
|extension| format!(".part.{extension}"),
);
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
let temporary = match tempfile::Builder::new()
.prefix("temporary-")
.suffix(&suffix)
.tempfile_in(&self.directory)
{
Ok(file) => Rc::new(file.into_temp_path()),
Err(error) => {
callback(Err(error.into()));
@ -183,6 +208,12 @@ impl ZipCache {
}
let part_path = self.directory.join(format!("{key}.part"));
if let Some(required_bytes) = entry.size
&& let Err(error) = self.make_room_for(required_bytes)
{
callback(Err(error));
return;
}
let destination = gio::File::for_path(&part_path);
let cache = Rc::clone(self);
source.copy_async(
@ -200,7 +231,7 @@ impl ZipCache {
final_path.display()
)
})?;
cache.enforce_quota(&final_path)?;
cache.enforce_quota(Some(&final_path))?;
Ok(cache.lease(final_path, entry.uri, entry.name))
});
if result.is_err() {
@ -211,11 +242,35 @@ impl ZipCache {
);
}
fn enforce_quota(&self, protected: &Path) -> Result<()> {
pub fn clear_persistent(&self) -> Result<CacheCleanupReport> {
self.cleanup_to_limit(0, None)
}
fn make_room_for(&self, required_bytes: u64) -> Result<()> {
let limit = self.quota_bytes.get().saturating_sub(required_bytes);
let report = self.cleanup_to_limit(limit, None)?;
if report.remaining_bytes > limit {
bail!("열려 있는 ZIP 때문에 다운로드에 필요한 캐시 공간을 확보할 수 없습니다");
}
Ok(())
}
fn enforce_quota(&self, protected: Option<&Path>) -> Result<CacheCleanupReport> {
self.cleanup_to_limit(self.quota_bytes.get(), protected)
}
fn cleanup_to_limit(&self, limit: u64, protected: Option<&Path>) -> Result<CacheCleanupReport> {
let mut files = Vec::new();
let mut total = 0_u64;
for entry in fs::read_dir(&self.directory)? {
let path = entry?.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"))
{
continue;
}
if path.extension().is_none_or(|extension| extension != "zip") {
continue;
}
@ -224,18 +279,26 @@ impl ZipCache {
files.push((metadata.modified().ok(), metadata.len(), path));
}
files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
let mut report = CacheCleanupReport::default();
for (_, size, path) in files {
if total <= self.quota_bytes {
if total <= limit {
break;
}
if path != protected
&& !self.pinned.borrow().contains_key(&path)
&& fs::remove_file(&path).is_ok()
{
total = total.saturating_sub(size);
if protected == Some(path.as_path()) {
continue;
}
if self.pinned.borrow().contains_key(&path) {
report.retained_open_files += 1;
continue;
}
fs::remove_file(&path)
.with_context(|| format!("캐시 파일을 삭제할 수 없습니다: {}", path.display()))?;
total = total.saturating_sub(size);
report.removed_files += 1;
report.removed_bytes = report.removed_bytes.saturating_add(size);
}
Ok(())
report.remaining_bytes = total;
Ok(report)
}
fn lease(
@ -351,7 +414,7 @@ mod tests {
fs::write(&second, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: 8,
quota_bytes: Cell::new(8),
pinned: RefCell::new(HashMap::new()),
});
@ -360,13 +423,104 @@ mod tests {
"file:///first.zip".into(),
"first.zip".into(),
);
cache.enforce_quota(&second).unwrap();
cache.enforce_quota(Some(&second)).unwrap();
assert!(first.exists());
assert!(second.exists());
drop(lease);
cache.enforce_quota(&second).unwrap();
cache.enforce_quota(Some(&second)).unwrap();
assert!(!first.exists());
assert!(second.exists());
}
#[test]
fn lru_removes_oldest_cache_file_first() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(8),
pinned: RefCell::new(HashMap::new()),
};
let report = cache.enforce_quota(None).unwrap();
assert_eq!(report.removed_files, 1);
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn download_reserves_space_before_writing() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
};
cache.make_room_for(8).unwrap();
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn zero_quota_clears_existing_cache_on_startup() {
let directory = tempfile::tempdir().unwrap();
let cached = directory.path().join("cached.zip");
fs::write(&cached, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 0).unwrap();
assert!(!cached.exists());
}
#[test]
fn startup_removes_stale_temporary_downloads() {
let directory = tempfile::tempdir().unwrap();
let temporary = directory.path().join("temporary-abcd.part.mp4");
fs::write(&temporary, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 10).unwrap();
assert!(!temporary.exists());
}
#[test]
fn manual_clear_keeps_open_archives() {
let directory = tempfile::tempdir().unwrap();
let open = directory.path().join("open.zip");
let unused = directory.path().join("unused.zip");
let temporary = directory.path().join("temporary-download.part.zip");
fs::write(&open, [0_u8; 8]).unwrap();
fs::write(&unused, [0_u8; 8]).unwrap();
fs::write(&temporary, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
});
let lease = cache.lease(open.clone(), "file:///open.zip".into(), "open.zip".into());
let report = cache.clear_persistent().unwrap();
assert_eq!(report.removed_files, 1);
assert_eq!(report.retained_open_files, 1);
assert!(open.exists());
assert!(!unused.exists());
assert!(temporary.exists());
drop(lease);
}
}