Release v0.2.2

This commit is contained in:
burnintuna 2026-07-22 17:34:37 +09:00
parent 048df6355e
commit b31acd3f58
8 changed files with 309 additions and 16 deletions

View file

@ -3,8 +3,8 @@
## 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.
It browses local, SMB, FTP, and SFTP locations and opens ZIP archives,
standalone images, and videos.
## Technology
@ -20,10 +20,20 @@ ZIP archives.
- 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.
- Cancel a superseded remote directory request so only the latest request can
consume resources, update progress, or replace browser entries.
- 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.
- Evict the oldest unpinned ZIP cache files before a download needs space, at
startup, and after an open archive releases its cache pin. Allow users to clear
cached ZIPs without deleting open archives or reading history.
- Keep temporary remote media in the application cache directory, remove it when
its viewer closes, and clean stale temporary downloads on the next startup.
- Support JPEG, PNG, WebP, GIF, BMP, and AVIF images both standalone and in ZIP
archives. Play animated GIF and WebP frames with their delays and loop counts.
- Support common standalone video formats through GStreamer. In video playback,
Left/Right seek by five seconds and Up/Down adjust volume by five percent.
- 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
@ -33,6 +43,17 @@ ZIP archives.
- 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.
- Reuse viewer windows when moving between ZIP, image, and video files. Preserve
fullscreen state and keep the registered GTK titlebar widget stable while the
displayed header changes.
- Provide a toggleable file list on the right side of each viewer. Double-click
or Enter opens the selected viewable file; its context menu provides favorite
and download actions.
- Accept one local file or folder dropped onto the main window or a viewer.
Browse dropped folders in the main window; for dropped files, browse the
parent folder and open the exact naturally sorted item in a new viewer window.
- Save favorites for local and remote folders and files, and preserve the saved
location profile needed to reconnect to remote favorites.
- Save progress per ZIP and save the most recently read ZIP for resume support.
## Data Locations
@ -50,11 +71,23 @@ Do not write application state into the source tree.
- 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.
- Abort superseded GIO futures and guard asynchronous progress and completion
callbacks against stale generations or closed/reused windows.
- Handle file drops as `COPY` actions through `GdkFileList`. Inspect local paths
asynchronously, reject ambiguous/multiple drops, and never reuse the source
viewer window for a dropped file.
- Use atomic writes for configuration, database migrations, and completed remote
downloads. Incomplete downloads must use a distinct temporary suffix.
- Reserve persistent cache capacity before writing a known-size remote ZIP.
Cache cleanup must skip active temporary downloads and pinned/open archives.
- 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.
- Validate archive entry sizes, image dimensions, animation frame counts, and
cumulative decoded animation size before retaining decoded data.
- Cancel animation timers when pictures are hidden, viewers are replaced, or
windows close. GTK/GDK objects must only be created on the GTK main thread.
- Avoid strong `Rc` cycles between GTK signal handlers and controllers. Viewer
teardown must release temporary files, ZIP cache pins, textures, and timers.
- 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.
@ -74,8 +107,13 @@ 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.
Add focused tests for natural sorting, oldest-first cache eviction, pin and
temporary-file protection, metadata refresh, archive filtering, animated image
decoding, video seek/volume calculations, viewer sibling navigation, reading
progress, dropped-file sorting/index selection, and remote file change
detection. For UI or packaging changes, also run a GTK startup smoke test with
isolated XDG directories; release builds must verify the AppImage and downloaded
release bundle checksums.
## Git Practices

2
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "comicviewer"
version = "0.2.1"
version = "0.2.2"
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.2" date="2026-07-22" />
<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" />

View file

@ -92,5 +92,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.

View file

@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256`
- `dist/Comicviewer-0.2.2-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.1 release"
git tag -a v0.2.1 -m "Comicviewer v0.2.1"
git commit -m "Prepare v0.2.2 release"
git tag -a v0.2.2 -m "Comicviewer v0.2.2"
git push origin main
git push origin v0.2.1
git push origin v0.2.2
```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새

View file

@ -182,10 +182,36 @@ pub struct FileViewerControls {
pub sibling_navigator: Option<SiblingNavigator>,
pub favorite_control: FavoriteControl,
pub download_control: FileDownloadControl,
pub file_drop_control: FileDropControl,
pub reuse_window: Option<gtk::ApplicationWindow>,
pub view_mode_control: Option<ViewModeControl>,
}
#[derive(Clone)]
pub struct FileDropControl {
pub handle: Rc<dyn Fn(Vec<gtk::gio::File>, gtk::Window)>,
}
pub(crate) fn install_file_drop_target(window: &gtk::ApplicationWindow, control: FileDropControl) {
if window.has_css_class("comicviewer-file-drop-target") {
return;
}
window.add_css_class("comicviewer-file-drop-target");
let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY);
let weak_window = window.downgrade();
target.connect_drop(move |_, value, _, _| {
let Ok(file_list) = value.get::<gdk::FileList>() else {
return false;
};
let Some(window) = weak_window.upgrade() else {
return false;
};
(control.handle)(file_list.files(), window.upcast());
true
});
window.add_controller(target);
}
impl ViewerWindow {
pub fn open(
application: &gtk::Application,
@ -200,6 +226,7 @@ impl ViewerWindow {
sibling_navigator,
favorite_control,
download_control,
file_drop_control,
reuse_window,
view_mode_control: _,
} = controls;
@ -215,6 +242,7 @@ impl ViewerWindow {
.default_height(850)
.build()
});
install_file_drop_target(&window, file_drop_control);
ensure_viewer_close_handler(&window);
window.set_title(Some(&title));
let header = gtk::HeaderBar::new();
@ -1681,6 +1709,7 @@ impl MediaViewerWindow {
sibling_navigator,
favorite_control,
download_control,
file_drop_control,
reuse_window,
view_mode_control,
} = controls;
@ -1696,6 +1725,7 @@ impl MediaViewerWindow {
.default_height(850)
.build()
});
install_file_drop_target(&window, file_drop_control);
ensure_viewer_close_handler(&window);
window.set_title(Some(&title));
let header = gtk::HeaderBar::new();

View file

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::{Duration, Instant};
use anyhow::Result;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local};
use gtk::gio;
use gtk::gio::prelude::*;
@ -21,9 +21,9 @@ use crate::source::{
mount_operation, parent_within_root, root_file, validate_profile,
};
use crate::viewer::{
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
set_viewer_header,
FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow,
SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl,
ViewerWindow, install_file_drop_target, set_viewer_header,
};
use crate::zip_cache::ZipCache;
@ -171,6 +171,7 @@ impl ComicWindow {
case_insensitive_check,
fast_loading_switch,
);
install_file_drop_target(&controller.window, controller.file_drop_control());
controller.refresh();
Ok(Self { controller })
}
@ -956,6 +957,7 @@ impl BrowserController {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
file_drop_control: controller.file_drop_control(),
reuse_window,
view_mode_control: None,
},
@ -1019,6 +1021,7 @@ impl BrowserController {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
file_drop_control: controller.file_drop_control(),
reuse_window,
view_mode_control,
},
@ -1147,6 +1150,140 @@ impl BrowserController {
}
}
fn file_drop_control(self: &Rc<Self>) -> FileDropControl {
let controller = Rc::downgrade(self);
FileDropControl {
handle: Rc::new(move |files, parent| {
if let Some(controller) = controller.upgrade() {
controller.open_dropped_files(files, parent);
}
}),
}
}
fn open_dropped_files(self: &Rc<Self>, files: Vec<gio::File>, parent: gtk::Window) {
let [file] = files.as_slice() else {
show_drop_error(&parent, "한 번에 하나의 파일 또는 폴더만 열 수 있습니다.");
return;
};
if !file.is_native() {
show_drop_error(&parent, "로컬 파일 또는 폴더만 드래그해서 열 수 있습니다.");
return;
}
let file = file.clone();
self.cancel_listing();
let generation = {
let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1);
state.generation
};
self.status_label.set_text("드롭한 항목을 확인하는 중...");
let controller = Rc::downgrade(self);
let task = glib::MainContext::default().spawn_local(async move {
let result: Result<_> = async {
let info = file
.query_info_future(
"standard::type,standard::display-name",
gio::FileQueryInfoFlags::NONE,
glib::Priority::DEFAULT,
)
.await
.context("드롭한 항목을 확인할 수 없습니다")?;
let selected = match info.file_type() {
gio::FileType::Directory => None,
gio::FileType::Regular => {
if !classify_file(Path::new(info.display_name().as_str())).is_viewable() {
bail!("지원하지 않는 파일 형식입니다: {}", info.display_name());
}
Some(file.clone())
}
_ => bail!("일반 파일 또는 폴더만 열 수 있습니다"),
};
let directory = selected
.as_ref()
.and_then(gio::File::parent)
.unwrap_or_else(|| file.clone());
let profile = local_profile_for_directory(&directory)?;
let operation = mount_operation(&profile, &parent);
let entries =
enumerate_directory(&profile, directory.uri().as_str(), &operation, |_| {})
.await?;
Ok((directory, profile, entries, selected))
}
.await;
let Some(controller) = controller.upgrade() else {
return;
};
if controller.state.borrow().generation != generation || !parent.is_visible() {
return;
}
let (directory, profile, entries, selected) = match result {
Ok(result) => result,
Err(error) => {
let message = format!("드롭한 항목을 열 수 없습니다: {error:#}");
controller.status_label.set_text(&message);
show_drop_error(&parent, &message);
return;
}
};
controller.show_dropped_directory(&directory, profile.clone(), entries.clone());
let Some(selected) = selected else {
return;
};
let Some(entry) = entries
.iter()
.find(|entry| gio::File::for_uri(&entry.uri).equal(&selected))
.cloned()
else {
show_drop_error(&parent, "드롭한 파일을 부모 폴더에서 찾을 수 없습니다.");
return;
};
let state = controller.state.borrow();
let Some((siblings, _selected_index)) = sorted_navigable_selection(
&entries,
&entry.uri,
state.sort_field,
state.sort_direction,
controller.config.borrow().case_insensitive_sort,
) else {
drop(state);
show_drop_error(&parent, "드롭한 파일의 목록 위치를 확인할 수 없습니다.");
return;
};
drop(state);
controller.open_viewable(entry, Rc::new(siblings), profile, None);
});
self.listing_task.replace(Some(task));
}
fn show_dropped_directory(
&self,
directory: &gio::File,
profile: LocationProfile,
entries: Vec<BrowserEntry>,
) {
self.location_dropdown
.set_selected(gtk::INVALID_LIST_POSITION);
self.edit_location_button.set_sensitive(false);
self.delete_location_button.set_sensitive(false);
self.path_entry.set_editable(true);
let uri: String = directory.uri().into();
let mut state = self.state.borrow_mut();
state.favorite_profile = Some(profile);
state.browsing_favorites = false;
state.root_uri = uri.clone();
state.current_uri = uri.clone();
state.entries = entries;
state.typeahead_query.clear();
state.typeahead_last_input = None;
drop(state);
self.path_entry.set_text(&display_uri(&uri));
self.search_entry.set_text("");
self.render();
self.window.present();
}
fn view_mode_control(self: &Rc<Self>) -> ViewModeControl {
let selected = self.config.borrow().viewer_mode.dropdown_index();
let controller = Rc::downgrade(self);
@ -1599,6 +1736,37 @@ fn home_profile() -> LocationProfile {
}
}
fn local_profile_for_directory(directory: &gio::File) -> Result<LocationProfile> {
let path = directory
.path()
.context("로컬 폴더 경로를 확인할 수 없습니다")?;
let address = path
.to_str()
.context("UTF-8로 표현할 수 없는 로컬 경로는 열 수 없습니다")?;
let name = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(address)
.to_owned();
Ok(LocationProfile {
name,
kind: LocationKind::Local,
address: address.to_owned(),
username: String::new(),
password: String::new(),
domain: String::new(),
})
}
fn show_drop_error(parent: &gtk::Window, message: &str) {
gtk::AlertDialog::builder()
.modal(true)
.message(message)
.build()
.show(Some(parent));
}
fn display_uri(uri: &str) -> String {
let file = gio::File::for_uri(uri);
file.path().map_or_else(
@ -1682,6 +1850,24 @@ fn sorted_navigable_entries(
siblings
}
fn sorted_navigable_selection(
entries: &[BrowserEntry],
selected_uri: &str,
sort_field: SortField,
sort_direction: SortDirection,
case_insensitive: bool,
) -> Option<(Vec<BrowserEntry>, usize)> {
let entries = sorted_navigable_entries(
entries,
EntryKind::Image,
sort_field,
sort_direction,
case_insensitive,
);
let selected = entries.iter().position(|entry| entry.uri == selected_uri)?;
Some((entries, selected))
}
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
BrowserEntry {
uri: favorite.uri.clone(),
@ -1835,6 +2021,42 @@ mod tests {
);
}
#[test]
fn dropped_file_selection_uses_sorted_parent_index() {
let entries = [
media_entry("page10.jpg"),
entry("folder"),
media_entry("page2.jpg"),
];
let selected_uri = entries[0].uri.clone();
let (siblings, selected) = sorted_navigable_selection(
&entries,
&selected_uri,
SortField::Name,
SortDirection::Ascending,
true,
)
.unwrap();
assert_eq!(siblings.len(), 2);
assert_eq!(siblings[0].name, "page2.jpg");
assert_eq!(siblings[selected].name, "page10.jpg");
}
#[test]
fn dropped_directory_creates_local_root_profile() {
let directory = tempfile::tempdir().unwrap();
let file = gio::File::for_path(directory.path());
let profile = local_profile_for_directory(&file).unwrap();
assert_eq!(profile.kind, LocationKind::Local);
assert_eq!(Path::new(&profile.address), directory.path());
assert!(profile.username.is_empty());
assert!(profile.password.is_empty());
}
#[test]
fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile();