Compare commits

...

20 commits
v0.2.1 ... main

Author SHA1 Message Date
10574773c6 Release v0.2.20 2026-08-21 23:35:49 +09:00
bae8699674 Release v0.2.19 2026-08-21 23:24:30 +09:00
8f0578c0d3 Release v0.2.18 2026-08-21 22:49:27 +09:00
f01365031c Release v0.2.17 2026-08-21 22:39:24 +09:00
d1bdefdeff Release v0.2.16 2026-08-21 22:31:22 +09:00
9aac91ccb1 Release v0.2.15 2026-08-21 22:06:04 +09:00
063770749e Release v0.2.14 2026-08-21 21:47:59 +09:00
605248a969 Release v0.2.13 2026-08-21 21:29:25 +09:00
195bd2554f Ignore local release credentials 2026-07-29 08:36:19 +09:00
77d6e3b68b Release v0.2.12 2026-07-29 08:32:58 +09:00
7d6202e0a2 Release v0.2.11 2026-07-29 02:22:16 +09:00
f7c67c1a79 Release v0.2.10 2026-07-24 23:44:49 +09:00
ae1f0c7c4a Release v0.2.9 2026-07-24 22:13:07 +09:00
6c54ec8139 Release v0.2.8 2026-07-24 21:55:41 +09:00
f4a0c484ff Release v0.2.7 2026-07-24 02:36:05 +09:00
be02626912 Release v0.2.6 2026-07-23 21:59:55 +09:00
b7a212c991 Release v0.2.5 2026-07-23 21:14:20 +09:00
cbc762fb4c Release v0.2.4 2026-07-23 21:03:11 +09:00
86f3185c59 Release v0.2.3 2026-07-23 20:09:11 +09:00
b31acd3f58 Release v0.2.2 2026-07-22 17:34:37 +09:00
16 changed files with 3414 additions and 343 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@
/dist/*.tar.gz.part-* /dist/*.tar.gz.part-*
/dist/*.tar.gz.parts.sha256 /dist/*.tar.gz.parts.sha256
/dist/*-install.sh /dist/*-install.sh
/.release-credentials

View file

@ -3,14 +3,14 @@
## Project Overview ## Project Overview
Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux. 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 It browses local, SMB, FTP, and SFTP locations and opens ZIP archives,
ZIP archives. standalone images, and videos.
## Technology ## Technology
- Rust - Rust
- GTK4 with GIO/GVfs for desktop and remote filesystem integration - GTK4 with GIO/GVfs for desktop and remote filesystem integration
- SQLite for cached directory metadata, search indexes, and reading history - SQLite for cached directory metadata and reading history
- AppImage as the primary distribution format - AppImage as the primary distribution format
- Korean-first UI with all user-facing strings kept translatable - Korean-first UI with all user-facing strings kept translatable
@ -20,20 +20,46 @@ ZIP archives.
- Store credentials as plain text in the application configuration as requested, - Store credentials as plain text in the application configuration as requested,
but restrict the configuration file permissions to `0600`. but restrict the configuration file permissions to `0600`.
- Cache remote directory metadata and refresh it asynchronously when browsing. - 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. - Download remote ZIP archives before opening them.
- Limit the persistent ZIP cache with a user-configurable LRU quota. The default - 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. 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. - Sort archive images and browser entries using natural filename ordering.
- Support ascending and descending sorting by name, modification time, and - Support ascending and descending sorting by name, modification time, and
creation time. Treat unavailable creation times as unknown rather than creation time. Treat unavailable creation times as unknown rather than
inventing values. inventing values.
- Search folder and ZIP names recursively below the current location using the - Filter the currently displayed browser directory by filename and restore all
cached index. entries when the search text is cleared.
- Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - 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 - Support both right-to-left and left-to-right two-page layouts, plus controls
that advance by one page to adjust page pairing. that advance by one page to adjust page pairing. Persist the last selected
two-page direction and restore it in newly opened archive viewers.
- 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.
- Filter each viewer's current file list with a temporary search field and
restore the complete list when the search text is cleared.
- 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. - Save progress per ZIP and save the most recently read ZIP for resume support.
- Let users update from the About window by downloading the fixed Cafe24 latest
bundle and checksum, verifying SHA-256, and reusing the atomic installer.
## Data Locations ## Data Locations
@ -50,11 +76,23 @@ Do not write application state into the source tree.
- Prefer the smallest correct implementation and avoid speculative abstractions. - Prefer the smallest correct implementation and avoid speculative abstractions.
- Keep network and archive work off the GTK main thread. - Keep network and archive work off the GTK main thread.
- Show cached directory data immediately, then update it in the background. - 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 - Use atomic writes for configuration, database migrations, and completed remote
downloads. Incomplete downloads must use a distinct temporary suffix. 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 - Do not extract an entire archive to disk. Decode only requested images and
prefetch a small window around the current page. 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. - Never trust archive paths or use them as extraction destinations.
- Preserve errors from GIO, ZIP parsing, and image decoding and present concise, - Preserve errors from GIO, ZIP parsing, and image decoding and present concise,
actionable messages in the UI. actionable messages in the UI.
@ -74,8 +112,13 @@ cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features cargo test --all-features
``` ```
Add focused tests for natural sorting, cache eviction, metadata refresh, Add focused tests for natural sorting, oldest-first cache eviction, pin and
archive filtering, reading progress, and remote file change detection. 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 ## Git Practices

25
Cargo.lock generated
View file

@ -182,17 +182,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "comicviewer" name = "comicviewer"
version = "0.2.1" version = "0.2.20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"filetime", "filetime",
"flate2",
"gtk4", "gtk4",
"image", "image",
"percent-encoding", "percent-encoding",
"rusqlite", "rusqlite",
"serde", "serde",
"sha2", "sha2",
"tar",
"tempfile", "tempfile",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"zip", "zip",
@ -1211,6 +1213,17 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@ -1475,6 +1488,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.54" version = "0.8.54"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "comicviewer" name = "comicviewer"
version = "0.2.1" version = "0.2.20"
edition = "2024" edition = "2024"
description = "A comic archive viewer for local and remote locations" description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@ -8,14 +8,16 @@ license = "GPL-3.0-or-later"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_12"] }
image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] }
filetime = "0.2" filetime = "0.2"
flate2 = "1.0"
percent-encoding = "2.3" percent-encoding = "2.3"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" sha2 = "0.10"
tempfile = "3.20" tempfile = "3.20"
tar = "0.4"
toml = "0.9" toml = "0.9"
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "8", default-features = false, features = ["deflate"] }

View file

@ -4,9 +4,9 @@ Name=Comicviewer
Name[ko]=코믹뷰어 Name[ko]=코믹뷰어
Comment=Read comic images stored in ZIP archives Comment=Read comic images stored in ZIP archives
Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다 Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다
Exec=comicviewer Exec=comicviewer %f
Icon=io.github.comicviewer.Comicviewer Icon=io.github.comicviewer.Comicviewer
Terminal=false Terminal=false
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
MimeType=application/zip;application/vnd.comicbook+zip; MimeType=application/zip;image/jpeg;image/png;image/webp;image/gif;image/bmp;image/avif;video/mp4;video/x-m4v;video/matroska;video/x-matroska;video/webm;video/quicktime;video/vnd.avi;video/x-msvideo;video/mpeg;video/ogg;inode/directory;
StartupNotify=true StartupNotify=true

View file

@ -26,6 +26,25 @@
</provides> </provides>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.2.20" date="2026-08-21" />
<release version="0.2.19" date="2026-08-21" />
<release version="0.2.18" date="2026-08-21" />
<release version="0.2.17" date="2026-08-21" />
<release version="0.2.16" date="2026-08-21" />
<release version="0.2.15" date="2026-08-21" />
<release version="0.2.14" date="2026-08-21" />
<release version="0.2.13" date="2026-08-21" />
<release version="0.2.12" date="2026-07-29" />
<release version="0.2.11" date="2026-07-24" />
<release version="0.2.10" date="2026-07-24" />
<release version="0.2.9" date="2026-07-24" />
<release version="0.2.8" date="2026-07-24" />
<release version="0.2.7" date="2026-07-24" />
<release version="0.2.6" date="2026-07-23" />
<release version="0.2.5" date="2026-07-23" />
<release version="0.2.4" date="2026-07-23" />
<release version="0.2.3" date="2026-07-23" />
<release version="0.2.2" date="2026-07-22" />
<release version="0.2.1" 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.2.0" date="2026-07-21" />
<release version="0.1.4" date="2026-07-21" /> <release version="0.1.4" date="2026-07-21" />

View file

@ -9,15 +9,16 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다. 설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
```sh ```sh
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
``` ```
- `fuse2`: AppImage 마운트 및 실행 - `fuse2`: AppImage 마운트 및 실행
- `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩 - `gtk4` 4.12 이상, `dav1d`: 화면 표시와 이미지 디코딩
- `gvfs`: FTP와 SFTP 접근 - `gvfs`: FTP와 SFTP 접근
- `gvfs-smb`: SMB 공유 접근 - `gvfs-smb`: SMB 공유 접근
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
- `curl`: About 창에서 최신 업데이트 다운로드
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
@ -82,6 +83,9 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다. - 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다.
- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. - 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. - 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
- 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다.
- 위치 목록의 `최근 열어본 파일`은 성공적으로 연 ZIP·이미지·동영상을 최신순으로 최대 100개 보관합니다.
- 최근 파일 기록에는 원격 위치 재접속 정보가 함께 저장되며 `최근 기록 비우기`로 전체 기록만 삭제할 수 있습니다.
## 뷰어 조작 ## 뷰어 조작
@ -89,8 +93,19 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 - `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 - 이미지에서 `1`, `2`, `3`, `4`: 폭 맞춤, 높이 맞춤, 2페이지, 100%로 전환 (`3`은 ZIP 뷰어에서 사용)
- 창보다 크게 표시된 이미지에서 `←`, `→`, `↑`, `↓`: 해당 방향으로 최대 100px 이동
- `L`: 우측 파일 목록 표시 또는 숨기기
- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원
- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치
- 이미지에서 `+`/`=`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 2페이지의 `좌→우`/`우→좌` 방향은 마지막 선택을 저장해 다음 뷰어에서 복원
- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시
- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 - 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다. 애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.

View file

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

View file

@ -39,7 +39,7 @@ install_dependencies() {
fi fi
set -- set --
for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \ for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl gst-plugins-base \
gst-plugins-good gst-plugins-bad gst-libav; do gst-plugins-good gst-plugins-bad gst-libav; do
if ! pacman -Q "${package}" >/dev/null 2>&1; then if ! pacman -Q "${package}" >/dev/null 2>&1; then
set -- "$@" "${package}" set -- "$@" "${package}"
@ -109,7 +109,7 @@ temporary_appimage=""
ln -sfn "${installed_appimage}" "${launcher}" ln -sfn "${installed_appimage}" "${launcher}"
while IFS= read -r line || [ -n "${line}" ]; do while IFS= read -r line || [ -n "${line}" ]; do
case "${line}" in case "${line}" in
Exec=*) printf 'Exec="%s"\n' "${launcher}" ;; Exec=*) printf 'Exec="%s" %%f\n' "${launcher}" ;;
*) printf '%s\n' "${line}" ;; *) printf '%s\n' "${line}" ;;
esac esac
done < "${desktop_file}" > "${temporary_desktop}" done < "${desktop_file}" > "${temporary_desktop}"

View file

@ -21,6 +21,7 @@ impl EntryKind {
pub struct BrowserEntry { pub struct BrowserEntry {
pub uri: String, pub uri: String,
pub name: String, pub name: String,
pub list_name: Option<String>,
pub kind: EntryKind, pub kind: EntryKind,
pub size: Option<u64>, pub size: Option<u64>,
pub modified: Option<SystemTime>, pub modified: Option<SystemTime>,
@ -28,6 +29,12 @@ pub struct BrowserEntry {
pub etag: Option<String>, pub etag: Option<String>,
} }
impl BrowserEntry {
pub fn display_name(&self) -> &str {
self.list_name.as_deref().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortField { pub enum SortField {
Name, Name,
@ -62,20 +69,28 @@ pub fn sort_entries(
match field { match field {
SortField::Name => apply_direction( SortField::Name => apply_direction(
natural_cmp_with_case(&left.name, &right.name, case_insensitive), natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive),
direction, direction,
), ),
SortField::Modified => compare_optional_time(left.modified, right.modified, direction) SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
.then_with(|| { .then_with(|| {
apply_direction( apply_direction(
natural_cmp_with_case(&left.name, &right.name, case_insensitive), natural_cmp_with_case(
left.display_name(),
right.display_name(),
case_insensitive,
),
direction, direction,
) )
}), }),
SortField::Created => compare_optional_time(left.created, right.created, direction) SortField::Created => compare_optional_time(left.created, right.created, direction)
.then_with(|| { .then_with(|| {
apply_direction( apply_direction(
natural_cmp_with_case(&left.name, &right.name, case_insensitive), natural_cmp_with_case(
left.display_name(),
right.display_name(),
case_insensitive,
),
direction, direction,
) )
}), }),
@ -212,6 +227,30 @@ mod tests {
); );
} }
#[test]
fn recursive_entries_sort_by_relative_display_path() {
let mut entries = ["10권/page.jpg", "2권/page.jpg"].map(|list_name| BrowserEntry {
uri: format!("file:///{list_name}"),
name: "page.jpg".into(),
list_name: Some(list_name.into()),
kind: EntryKind::Image,
size: None,
modified: None,
created: None,
etag: None,
});
sort_entries(
&mut entries,
SortField::Name,
SortDirection::Ascending,
true,
);
assert_eq!(entries[0].display_name(), "2권/page.jpg");
assert_eq!(entries[1].display_name(), "10권/page.jpg");
}
#[test] #[test]
fn zip_extension_is_case_insensitive() { fn zip_extension_is_case_insensitive() {
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);

View file

@ -12,8 +12,10 @@ pub struct AppConfig {
pub fast_image_loading: bool, pub fast_image_loading: bool,
pub case_insensitive_sort: bool, pub case_insensitive_sort: bool,
pub viewer_mode: ViewerMode, pub viewer_mode: ViewerMode,
pub two_page_right_to_left: bool,
pub locations: Vec<LocationProfile>, pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>, pub favorites: Vec<FavoriteEntry>,
pub recent_files: Vec<RecentFileEntry>,
} }
impl Default for AppConfig { impl Default for AppConfig {
@ -23,8 +25,10 @@ impl Default for AppConfig {
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true, case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
two_page_right_to_left: true,
locations: Vec::new(), locations: Vec::new(),
favorites: Vec::new(), favorites: Vec::new(),
recent_files: Vec::new(),
} }
} }
} }
@ -50,6 +54,13 @@ pub struct FavoriteEntry {
pub profile: LocationProfile, pub profile: LocationProfile,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentFileEntry {
pub name: String,
pub uri: String,
pub profile: LocationProfile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum LocationKind { pub enum LocationKind {
@ -179,6 +190,7 @@ mod tests {
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true, case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
two_page_right_to_left: true,
locations: vec![LocationProfile { locations: vec![LocationProfile {
name: "테스트".into(), name: "테스트".into(),
kind: LocationKind::Sftp, kind: LocationKind::Sftp,
@ -200,6 +212,18 @@ mod tests {
domain: String::new(), domain: String::new(),
}, },
}], }],
recent_files: vec![RecentFileEntry {
name: "최근 영상.mp4".into(),
uri: "sftp://example.invalid/comics/recent.mp4".into(),
profile: LocationProfile {
name: "최근 서버".into(),
kind: LocationKind::Sftp,
address: "sftp://example.invalid/comics".into(),
username: "recent-reader".into(),
password: "recent-password".into(),
domain: String::new(),
},
}],
}; };
let encoded = toml::to_string(&config).unwrap(); let encoded = toml::to_string(&config).unwrap();
@ -207,6 +231,8 @@ mod tests {
assert_eq!(decoded.locations[0].password, "plain-password"); assert_eq!(decoded.locations[0].password, "plain-password");
assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); assert_eq!(decoded.favorites[0].profile.username, "favorite-reader");
assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); assert_eq!(decoded.favorites[0].profile.password, "favorite-password");
assert_eq!(decoded.recent_files[0].profile.username, "recent-reader");
assert_eq!(decoded.recent_files[0].profile.password, "recent-password");
} }
#[test] #[test]
@ -215,7 +241,9 @@ mod tests {
assert!(decoded.fast_image_loading); assert!(decoded.fast_image_loading);
assert!(decoded.case_insensitive_sort); assert!(decoded.case_insensitive_sort);
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
assert!(decoded.two_page_right_to_left);
assert!(decoded.favorites.is_empty()); assert!(decoded.favorites.is_empty());
assert!(decoded.recent_files.is_empty());
} }
#[test] #[test]
@ -231,4 +259,17 @@ mod tests {
} }
assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth);
} }
#[test]
fn two_page_direction_defaults_to_rtl_and_round_trips_ltr() {
let existing: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
assert!(existing.two_page_right_to_left);
let config = AppConfig {
two_page_right_to_left: false,
..AppConfig::default()
};
let decoded: AppConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
assert!(!decoded.two_page_right_to_left);
}
} }

View file

@ -8,36 +8,86 @@ mod viewer;
mod window; mod window;
mod zip_cache; mod zip_cache;
use std::cell::RefCell;
use std::rc::Rc;
use anyhow::Result; use anyhow::Result;
use gtk::gio;
use gtk::prelude::*; use gtk::prelude::*;
const APP_ID: &str = "io.github.comicviewer.Comicviewer"; const APP_ID: &str = "io.github.comicviewer.Comicviewer";
fn main() { fn main() {
let application = gtk::Application::builder().application_id(APP_ID).build(); let application = gtk::Application::builder()
.application_id(APP_ID)
.flags(application_flags())
.build();
let state = Rc::new(ApplicationState::default());
application.connect_activate(|application| { let activate_state = Rc::clone(&state);
if let Err(error) = activate(application) { application.connect_activate(move |application| {
eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}"); match activate_state.ensure_main_window(application) {
application.quit(); Ok(window) => window.present(),
Err(error) => startup_failed(application, &error),
}
});
application.connect_open(move |application, files, _| {
match state.ensure_main_window(application) {
Ok(window) => window.open_local_items(files),
Err(error) => startup_failed(application, &error),
} }
}); });
application.run(); application.run();
} }
fn activate(application: &gtk::Application) -> Result<()> { #[derive(Default)]
struct ApplicationState {
main_window: RefCell<Option<Rc<window::ComicWindow>>>,
}
impl ApplicationState {
fn ensure_main_window(
&self,
application: &gtk::Application,
) -> Result<Rc<window::ComicWindow>> {
if let Some(window) = self.main_window.borrow().as_ref() {
return Ok(Rc::clone(window));
}
let app_paths = paths::AppPaths::discover()?; let app_paths = paths::AppPaths::discover()?;
let config = config::AppConfig::load_or_create(&app_paths.config_file)?; let config = config::AppConfig::load_or_create(&app_paths.config_file)?;
let database = database::Database::open(&app_paths.database_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 zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?;
let window = window::ComicWindow::new( let window = Rc::new(window::ComicWindow::new(
application, application,
config, config,
app_paths.config_file, app_paths.config_file,
database, database,
zip_cache, zip_cache,
)?; )?);
window.present(); self.main_window.replace(Some(Rc::clone(&window)));
Ok(()) Ok(window)
}
}
fn startup_failed(application: &gtk::Application, error: &anyhow::Error) {
eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}");
application.quit();
}
fn application_flags() -> gio::ApplicationFlags {
gio::ApplicationFlags::HANDLES_OPEN
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn application_accepts_files_as_a_single_instance() {
let flags = application_flags();
assert!(flags.contains(gio::ApplicationFlags::HANDLES_OPEN));
assert!(!flags.contains(gio::ApplicationFlags::NON_UNIQUE));
assert!(!flags.contains(gio::ApplicationFlags::HANDLES_COMMAND_LINE));
}
} }

View file

@ -1,3 +1,4 @@
use std::collections::{HashSet, VecDeque};
use std::path::Path; use std::path::Path;
use std::time::{Duration, UNIX_EPOCH}; use std::time::{Duration, UNIX_EPOCH};
@ -11,6 +12,43 @@ use crate::browser::{BrowserEntry, EntryKind, classify_file};
use crate::config::{LocationKind, LocationProfile}; 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"; const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
const RECURSIVE_ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::is-symlink,standard::size,time::modified,time::created,etag::value,id::file,id::filesystem";
const MAX_RECURSIVE_DIRECTORIES: usize = 10_000;
const MAX_RECURSIVE_ENTRIES: usize = 100_000;
#[derive(Default)]
struct EnumerationProgress {
seen: HashSet<String>,
new_in_batch: usize,
}
impl EnumerationProgress {
fn begin_batch(&mut self) {
self.new_in_batch = 0;
}
fn record(&mut self, uri: String) -> bool {
let inserted = self.seen.insert(uri);
self.new_in_batch += usize::from(inserted);
inserted
}
fn repeated_batch(&self) -> bool {
self.new_in_batch == 0
}
fn len(&self) -> usize {
self.seen.len()
}
}
#[derive(Clone, Copy, Default)]
pub struct RecursiveProgress {
pub directories: usize,
pub scanned: usize,
pub viewable: usize,
pub skipped: usize,
}
pub fn root_file(profile: &LocationProfile) -> Result<gio::File> { pub fn root_file(profile: &LocationProfile) -> Result<gio::File> {
validate_profile(profile)?; validate_profile(profile)?;
@ -100,7 +138,7 @@ where
} }
}; };
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut scanned = 0; let mut enumeration = EnumerationProgress::default();
loop { loop {
let batch = enumerator let batch = enumerator
@ -109,22 +147,25 @@ where
if batch.is_empty() { if batch.is_empty() {
break; break;
} }
scanned += batch.len(); enumeration.begin_batch();
progress(scanned);
for info in batch { for info in batch {
let child = enumerator.child(&info);
let uri: String = info
.attribute_string("standard::target-uri")
.map_or_else(|| child.uri().into(), Into::into);
if !enumeration.record(uri.clone()) {
continue;
}
let kind = match info.file_type() { let kind = match info.file_type() {
gio::FileType::Directory => EntryKind::Directory, gio::FileType::Directory => EntryKind::Directory,
gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory,
gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())),
_ => continue, _ => 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 { entries.push(BrowserEntry {
uri, uri,
name: info.display_name().into(), name: info.display_name().into(),
list_name: None,
kind, kind,
size: info size: info
.has_attribute("standard::size") .has_attribute("standard::size")
@ -140,10 +181,151 @@ where
.map(Into::into), .map(Into::into),
}); });
} }
if enumeration.repeated_batch() {
break;
}
progress(enumeration.len());
} }
Ok(entries) Ok(entries)
} }
pub async fn enumerate_tree_entries<F>(
profile: &LocationProfile,
root: &gio::File,
operation: Option<&gtk::MountOperation>,
mut progress: F,
) -> Result<(Vec<BrowserEntry>, RecursiveProgress)>
where
F: FnMut(RecursiveProgress),
{
if profile.kind != LocationKind::Local && !root.is_native() {
let operation = operation.context("원격 폴더 마운트 정보가 없습니다")?;
ensure_mounted(root, profile, operation).await?;
}
let mut pending = VecDeque::from([(root.clone(), String::new())]);
let mut visited = HashSet::new();
let mut visited_identities = HashSet::new();
if let Ok(info) = root
.query_info_future(
"id::file,id::filesystem",
gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS,
glib::Priority::DEFAULT,
)
.await
&& let Some(identity) = recursive_file_identity(&info)
{
visited_identities.insert(identity);
}
let mut entries = Vec::new();
let mut state = RecursiveProgress::default();
while let Some((directory, relative_directory)) = pending.pop_front() {
let directory_uri: String = directory.uri().into();
if !visited.insert(directory_uri.clone()) {
continue;
}
if visited.len() > MAX_RECURSIVE_DIRECTORIES {
bail!("하위 폴더 수가 안전 제한을 초과합니다 (최대 {MAX_RECURSIVE_DIRECTORIES}개)");
}
let enumerator = match directory
.enumerate_children_future(
RECURSIVE_ATTRIBUTES,
gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS,
glib::Priority::DEFAULT,
)
.await
{
Ok(enumerator) => enumerator,
Err(error) if directory.equal(root) => {
return Err(error)
.with_context(|| format!("폴더에 접근할 수 없습니다: {}", directory.uri()));
}
Err(_) => {
state.skipped += 1;
progress(state);
continue;
}
};
state.directories += 1;
loop {
let batch = enumerator
.next_files_future(128, glib::Priority::DEFAULT)
.await?;
if batch.is_empty() {
break;
}
state.scanned += batch.len();
if state.scanned > MAX_RECURSIVE_ENTRIES {
bail!("하위 항목 수가 안전 제한을 초과합니다 (최대 {MAX_RECURSIVE_ENTRIES}개)");
}
for info in batch {
let child = enumerator.child(&info);
if info.is_symlink() || !is_within_root(&child, root) {
continue;
}
let display_name = info.display_name();
let relative_name = if relative_directory.is_empty() {
display_name.to_string()
} else {
format!("{relative_directory}/{display_name}")
};
match info.file_type() {
gio::FileType::Directory => {
if let Some(identity) = recursive_file_identity(&info)
&& !visited_identities.insert(identity)
{
continue;
}
pending.push_back((child.clone(), relative_name.clone()));
entries.push(BrowserEntry {
uri: child.uri().into(),
name: display_name.into(),
list_name: Some(relative_name),
kind: EntryKind::Directory,
size: None,
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),
});
}
gio::FileType::Regular => {
let kind = classify_file(Path::new(display_name.as_str()));
if !kind.is_viewable() {
continue;
}
state.viewable += 1;
entries.push(BrowserEntry {
uri: child.uri().into(),
name: display_name.into(),
list_name: Some(relative_name),
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),
});
}
_ => {}
}
}
progress(state);
}
}
Ok((entries, state))
}
pub fn mount_operation( pub fn mount_operation(
profile: &LocationProfile, profile: &LocationProfile,
parent: &impl glib::object::IsA<gtk::Window>, parent: &impl glib::object::IsA<gtk::Window>,
@ -239,8 +421,16 @@ fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option<std::time::Sy
.then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute))) .then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute)))
} }
fn recursive_file_identity(info: &gio::FileInfo) -> Option<String> {
let filesystem = info.attribute_string("id::filesystem")?;
let file = info.attribute_string("id::file")?;
Some(format!("{filesystem}:{file}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs;
use super::*; use super::*;
#[test] #[test]
@ -290,4 +480,63 @@ mod tests {
let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str()); let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str());
assert!(encoded_input.equal(&encoded)); assert!(encoded_input.equal(&encoded));
} }
#[test]
fn enumeration_allows_large_unique_directories_and_stops_repeated_batches() {
let mut enumeration = EnumerationProgress::default();
for batch_start in (0..330_001).step_by(128) {
enumeration.begin_batch();
for index in batch_start..(batch_start + 128).min(330_001) {
assert!(enumeration.record(format!("smb://server/share/file-{index}")));
}
assert!(!enumeration.repeated_batch());
}
assert_eq!(enumeration.len(), 330_001);
enumeration.begin_batch();
for index in 0..128 {
assert!(!enumeration.record(format!("smb://server/share/file-{index}")));
}
assert!(enumeration.repeated_batch());
assert_eq!(enumeration.len(), 330_001);
}
#[test]
fn recursively_lists_viewable_files_without_following_symlinks() {
let directory = tempfile::tempdir().unwrap();
let subdirectory = directory.path().join("2권");
fs::create_dir(&subdirectory).unwrap();
fs::write(directory.path().join("10.zip"), []).unwrap();
fs::write(subdirectory.join("page2.jpg"), []).unwrap();
fs::write(subdirectory.join("notes.txt"), []).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(directory.path(), subdirectory.join("loop")).unwrap();
let profile = LocationProfile {
name: "test".into(),
kind: LocationKind::Local,
address: directory.path().to_string_lossy().into_owned(),
username: String::new(),
password: String::new(),
domain: String::new(),
};
let root = gio::File::for_path(directory.path());
let (entries, progress) = glib::MainContext::default()
.block_on(enumerate_tree_entries(&profile, &root, None, |_| {}))
.unwrap();
assert!(
entries.iter().any(|entry| {
entry.kind == EntryKind::Directory && entry.display_name() == "2권"
})
);
assert!(entries.iter().any(|entry| {
entry.kind == EntryKind::ZipArchive && entry.display_name() == "10.zip"
}));
assert!(entries.iter().any(|entry| {
entry.kind == EntryKind::Image && entry.display_name() == "2권/page2.jpg"
}));
assert!(!entries.iter().any(|entry| entry.name == "notes.txt"));
assert!(!entries.iter().any(|entry| entry.name == "loop"));
assert_eq!(progress.skipped, 0);
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -375,6 +375,7 @@ mod tests {
let mut entry = BrowserEntry { let mut entry = BrowserEntry {
uri: "sftp://example.invalid/a.zip".into(), uri: "sftp://example.invalid/a.zip".into(),
name: "a.zip".into(), name: "a.zip".into(),
list_name: None,
kind: EntryKind::ZipArchive, kind: EntryKind::ZipArchive,
size: Some(10), size: Some(10),
modified: None, modified: None,