diff --git a/.gitignore b/.gitignore
index 6ce25f6..e899205 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,10 @@
/.appimage-tools/
*.AppImage
*.AppImage.zsync
+/dist/*.AppImage.sha256
+/dist/*.tar.gz
+/dist/*.tar.gz.sha256
+/dist/*.tar.gz.part-*
+/dist/*.tar.gz.parts.sha256
+/dist/*-install.sh
+/.release-credentials
diff --git a/AGENTS.md b/AGENTS.md
index 64ce761..39e7884 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,14 +3,14 @@
## 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
- Rust
- 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
- 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,
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
inventing values.
-- Search folder and ZIP names recursively below the current location using the
- cached index.
+- Filter the currently displayed browser directory by filename and restore all
+ entries when the search text is cleared.
- 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.
+ 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.
+- 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
@@ -50,11 +76,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 +112,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
diff --git a/Cargo.lock b/Cargo.lock
index 6731395..44ba40a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -182,17 +182,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "comicviewer"
-version = "0.1.0"
+version = "0.2.20"
dependencies = [
"anyhow",
"chrono",
"filetime",
+ "flate2",
"gtk4",
"image",
"percent-encoding",
"rusqlite",
"serde",
"sha2",
+ "tar",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"zip",
@@ -1211,6 +1213,17 @@ dependencies = [
"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]]
name = "target-lexicon"
version = "0.13.5"
@@ -1475,6 +1488,16 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.54"
diff --git a/Cargo.toml b/Cargo.toml
index cc97e95..3b72bf0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "comicviewer"
-version = "0.1.0"
+version = "0.2.20"
edition = "2024"
description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later"
@@ -8,14 +8,16 @@ license = "GPL-3.0-or-later"
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
-gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] }
+gtk = { package = "gtk4", version = "0.10", features = ["v4_12"] }
image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] }
filetime = "0.2"
+flate2 = "1.0"
percent-encoding = "2.3"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
tempfile = "3.20"
+tar = "0.4"
toml = "0.9"
zip = { version = "8", default-features = false, features = ["deflate"] }
diff --git a/README.md b/README.md
index cd0cdc1..2fcfc44 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,26 @@
# Comicviewer
-Comicviewer is a GTK4 desktop application for browsing and reading comic image
-archives stored locally or on SMB, FTP, and SFTP servers.
+Comicviewer is a GTK4 desktop application for browsing ZIP archives, images,
+and videos stored locally or on SMB, FTP, and SFTP servers.
+
+한국어 문서:
+
+- [설치 및 실행 안내](docs/INSTALL.ko.md)
+- [AppImage Release 배포 안내](docs/RELEASING.ko.md)
The project is currently in early development. It supports saved local, SMB,
FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache,
natural sorting, safe on-demand image decoding and prefetching, single-page and
-two-page viewing, continuous vertical scrolling, reading progress, and
-XDG-compliant configuration.
+two-page viewing, continuous vertical scrolling, standalone image and video
+playback, reading progress, and XDG-compliant configuration.
## Development dependencies
On CachyOS or Arch Linux:
```sh
-sudo pacman -S rust gtk4 sqlite gvfs gvfs-smb
+sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb gstreamer \
+ gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
```
Run the application with:
@@ -23,16 +29,17 @@ Run the application with:
cargo run
```
-GVfs provides the FTP and SFTP backends. `gvfs-smb` adds SMB support. These
-services must be installed on the host even when Comicviewer is distributed as
-an AppImage.
+The AppImage uses the host's GTK4 and dav1d libraries. GVfs provides the FTP
+and SFTP backends, while `gvfs-smb` adds SMB support. These packages must be
+installed on the host.
Application files are stored below the standard XDG configuration, data, and
cache directories. No runtime state is written into the source tree.
## AppImage
-The AppImage build uses `linuxdeploy` and `linuxdeploy-plugin-gtk`:
+The AppImage build uses `appimagetool`. The script can extract appimagetool
+from a linuxdeploy AppImage when it is not provided directly:
```sh
PATH="/path/to/appimage-tools:$PATH" \
diff --git a/data/io.github.comicviewer.Comicviewer.desktop b/data/io.github.comicviewer.Comicviewer.desktop
index 7578ae7..ee70b54 100644
--- a/data/io.github.comicviewer.Comicviewer.desktop
+++ b/data/io.github.comicviewer.Comicviewer.desktop
@@ -4,9 +4,9 @@ Name=Comicviewer
Name[ko]=코믹뷰어
Comment=Read comic images stored in ZIP archives
Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다
-Exec=comicviewer
+Exec=comicviewer %f
Icon=io.github.comicviewer.Comicviewer
Terminal=false
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
diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml
index 1aca08d..1949cfa 100644
--- a/data/io.github.comicviewer.Comicviewer.metainfo.xml
+++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml
@@ -26,6 +26,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dist/Comicviewer-0.1.0-x86_64.AppImage.sha256 b/dist/Comicviewer-0.1.0-x86_64.AppImage.sha256
new file mode 100644
index 0000000..1b41400
--- /dev/null
+++ b/dist/Comicviewer-0.1.0-x86_64.AppImage.sha256
@@ -0,0 +1 @@
+ae696a3913804a39f304d6b111fc987973e81a3a37aa5d50089e5dfe490ac2f0 Comicviewer-0.1.0-x86_64.AppImage
diff --git a/docs/INSTALL.ko.md b/docs/INSTALL.ko.md
new file mode 100644
index 0000000..4c2b483
--- /dev/null
+++ b/docs/INSTALL.ko.md
@@ -0,0 +1,111 @@
+# Comicviewer 설치 및 실행 안내
+
+Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디어 뷰어입니다.
+로컬 및 SMB, FTP, SFTP 위치의 ZIP, 이미지, 동영상 파일을 열 수 있습니다.
+
+## 1. 필수 패키지
+
+통합 번들의 설치 스크립트가 다음 필수 패키지 중 누락된 항목을 자동으로
+설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
+
+```sh
+sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \
+ gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
+```
+
+- `fuse2`: AppImage 마운트 및 실행
+- `gtk4` 4.12 이상, `dav1d`: 화면 표시와 이미지 디코딩
+- `gvfs`: FTP와 SFTP 접근
+- `gvfs-smb`: SMB 공유 접근
+- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
+- `curl`: About 창에서 최신 업데이트 다운로드
+
+## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
+
+다음 두 파일을 같은 폴더에 다운로드합니다.
+
+- [Comicviewer-latest-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz)
+- [Comicviewer-latest-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256)
+
+터미널에서 직접 다운로드할 수도 있습니다.
+
+```sh
+curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz
+curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256
+```
+
+체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다.
+
+```sh
+sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
+tar -xzf Comicviewer-latest-x86_64.tar.gz
+cd Comicviewer-latest-x86_64
+./install-or-update.sh
+```
+
+스크립트는 AppImage 체크섬을 검증하고 다음 작업을 수행합니다.
+
+- AppImage를 `~/.local/opt/comicviewer/Comicviewer.AppImage`에 설치
+- `~/.local/bin/comicviewer` 실행 링크 생성
+- 애플리케이션 메뉴용 데스크톱 파일과 아이콘 설치
+- 기존 버전이 있으면 검증된 새 AppImage로 원자적으로 교체
+
+이후 재배포된 새 번들을 다운로드해 같은 명령을 실행하면 업데이트됩니다.
+사용자 설정, 읽기 기록, 원격 ZIP 캐시는 그대로 유지됩니다.
+
+제거하려면 압축을 푼 번들 폴더에서 다음을 실행합니다.
+
+```sh
+./install-or-update.sh --uninstall
+```
+
+이 명령은 프로그램과 함께 저장한 위치 및 인증정보, 읽기 기록, 원격 ZIP 캐시를
+모두 삭제합니다. 삭제한 데이터는 복구할 수 없으므로 필요한 설정은 제거 전에
+별도로 백업합니다.
+
+## 데이터 저장 위치
+
+Comicviewer는 XDG 표준 경로를 사용합니다.
+
+- 설정: `${XDG_CONFIG_HOME:-~/.config}/comicviewer/`
+- 읽기 기록과 데이터: `${XDG_DATA_HOME:-~/.local/share}/comicviewer/`
+- 원격 ZIP 캐시: `${XDG_CACHE_HOME:-~/.cache}/comicviewer/`
+
+저장한 원격 위치의 사용자명과 비밀번호는 설정 파일에 평문으로 기록됩니다.
+설정 파일 권한은 `0600`으로 제한되지만, 설정 파일을 다른 사람에게 전달하거나
+공유하지 않아야 합니다.
+
+## 원격 파일 이용 참고 사항
+
+- SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다.
+- FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다.
+- 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다.
+- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다.
+- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
+- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
+- 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다.
+- 위치 목록의 `최근 열어본 파일`은 성공적으로 연 ZIP·이미지·동영상을 최신순으로 최대 100개 보관합니다.
+- 최근 파일 기록에는 원격 위치 재접속 정보가 함께 저장되며 `최근 기록 비우기`로 전체 기록만 삭제할 수 있습니다.
+
+## 뷰어 조작
+
+- `[`와 `]`: 폴더의 이전/다음 ZIP·이미지·동영상 열기
+- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
+- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
+- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
+- 이미지에서 `1`, `2`, `3`, `4`: 폭 맞춤, 높이 맞춤, 2페이지, 100%로 전환 (`3`은 ZIP 뷰어에서 사용)
+- 창보다 크게 표시된 이미지에서 `←`, `→`, `↑`, `↓`: 해당 방향으로 최대 100px 이동
+- `L`: 우측 파일 목록 표시 또는 숨기기
+- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원
+- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치
+- 이미지에서 `+`/`=`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
+- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
+- 2페이지의 `좌→우`/`우→좌` 방향은 마지막 선택을 저장해 다음 뷰어에서 복원
+- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시
+- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행
+- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
+- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
+- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
+- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기
+
+애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md
new file mode 100644
index 0000000..b124f8f
--- /dev/null
+++ b/docs/RELEASING.ko.md
@@ -0,0 +1,121 @@
+# AppImage Release 배포 안내
+
+지인에게 배포할 때는 AppImage를 Git 소스 이력에 추가하지 않고 Git 서버의
+Release 첨부 파일로 제공하는 방식을 권장합니다. 버전별 바이너리를 커밋하면
+저장소를 복제할 때 모든 과거 바이너리까지 내려받아 저장소가 빠르게 커집니다.
+
+기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이
+절차를 사용합니다.
+
+태그, 프로젝트 버전, AppImage의 내부 버전은 반드시 같아야 합니다. 사용자가
+받는 통합 번들은 버전과 관계없이 `Comicviewer-latest-x86_64.tar.gz`라는 고정
+이름을 사용합니다.
+
+## 1. 버전 갱신
+
+릴리스 전에 다음 위치의 버전이 일치하는지 확인합니다.
+
+- `Cargo.toml`의 패키지 버전
+- `data/io.github.comicviewer.Comicviewer.metainfo.xml`의 릴리스 버전과 날짜
+- `packaging/build-appimage.sh`의 출력 파일 버전
+
+## 2. 품질 검사
+
+```sh
+cargo fmt --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test --all-features
+```
+
+모든 검사를 통과한 소스에서 AppImage를 빌드합니다.
+
+## 3. AppImage 빌드
+
+`appimagetool`을 직접 지정하거나, appimagetool을 포함한
+`linuxdeploy-x86_64.AppImage`를 지정합니다. AppImage는 Arch/CachyOS 호스트의
+GTK4와 dav1d를 사용하며 시스템 라이브러리를 내부에 복사하지 않습니다.
+
+```sh
+PATH="/path/to/appimage-tools:$PATH" \
+LINUXDEPLOY="/path/to/appimage-tools/linuxdeploy-x86_64.AppImage" \
+./packaging/build-appimage.sh
+```
+
+완성된 AppImage는 `dist/`에 생성됩니다. 배포 전에 직접 실행해 기본 화면이
+열리는지 확인합니다.
+
+## 4. 통합 배포 번들 생성
+
+AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 번들을 한 번에
+생성합니다.
+
+```sh
+./packaging/build-release-bundle.sh
+```
+
+스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
+
+- `dist/Comicviewer-0.2.20-x86_64.AppImage.sha256`
+- `dist/Comicviewer-latest-x86_64.tar.gz`
+- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
+
+통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘,
+한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를
+보존하므로 ZIP보다 Linux 배포에 적합합니다.
+
+생성된 번들의 압축 목록과 체크섬을 확인합니다.
+
+```sh
+tar -tzf dist/Comicviewer-latest-x86_64.tar.gz
+cd dist
+sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
+```
+
+## 5. 소스 커밋과 태그
+
+버전 변경과 문서를 커밋한 뒤 주석 태그를 생성하고 푸시합니다. AppImage 자체는
+`git add -f`로 추가하지 않습니다.
+
+```sh
+git add Cargo.toml Cargo.lock data packaging README.md docs
+git commit -m "Release v0.2.20"
+git tag -a v0.2.20 -m "Comicviewer v0.2.20"
+git push origin main
+git push origin v0.2.20
+```
+
+이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
+패치 버전을 만듭니다.
+
+## 6. Cafe24 배포
+
+Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을
+`/www/Downloads/Comicviewer/`에 업로드합니다.
+
+- `Comicviewer-latest-x86_64.tar.gz`
+- `Comicviewer-latest-x86_64.tar.gz.sha256`
+
+업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과
+실제 설치를 확인합니다.
+
+```text
+https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz
+https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256
+```
+
+배포 안내에는 다음 내용을 사용합니다.
+
+```text
+지원 환경: CachyOS / Arch Linux x86_64
+필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb, gstreamer,
+gst-plugins-base, gst-plugins-good, gst-plugins-bad, gst-libav
+
+설치 또는 업데이트:
+위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다.
+sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
+tar -xzf Comicviewer-latest-x86_64.tar.gz
+cd Comicviewer-latest-x86_64
+./install-or-update.sh
+```
+
+사용자에게는 [`INSTALL.ko.md`](INSTALL.ko.md) 문서를 함께 안내합니다.
diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh
index b0fafed..087ee51 100755
--- a/packaging/build-appimage.sh
+++ b/packaging/build-appimage.sh
@@ -3,7 +3,10 @@ set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
linuxdeploy="${LINUXDEPLOY:-linuxdeploy-x86_64.AppImage}"
+appimagetool="${APPIMAGETOOL:-}"
output_dir="${OUTPUT_DIR:-${root_dir}/dist}"
+package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")"
+version="${package_id##*@}"
work_dir="$(mktemp -d)"
app_dir="${work_dir}/Comicviewer.AppDir"
@@ -14,34 +17,47 @@ trap cleanup EXIT
cargo build --manifest-path "${root_dir}/Cargo.toml" --release
mkdir -p "${output_dir}"
-mkdir -p "${app_dir}/usr/share/metainfo"
+mkdir -p \
+ "${app_dir}/usr/bin" \
+ "${app_dir}/usr/share/applications" \
+ "${app_dir}/usr/share/icons/hicolor/scalable/apps" \
+ "${app_dir}/usr/share/metainfo"
+install -m 0755 "${root_dir}/target/release/comicviewer" "${app_dir}/usr/bin/comicviewer"
+install -m 0755 "${root_dir}/packaging/AppRun" "${app_dir}/AppRun"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \
+ "${app_dir}/io.github.comicviewer.Comicviewer.desktop"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \
+ "${app_dir}/usr/share/applications/io.github.comicviewer.Comicviewer.desktop"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \
+ "${app_dir}/io.github.comicviewer.Comicviewer.svg"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \
+ "${app_dir}/usr/share/icons/hicolor/scalable/apps/io.github.comicviewer.Comicviewer.svg"
install -m 0644 \
"${root_dir}/data/io.github.comicviewer.Comicviewer.metainfo.xml" \
"${app_dir}/usr/share/metainfo/io.github.comicviewer.Comicviewer.appdata.xml"
-output_path="${output_dir}/Comicviewer-0.1.0-x86_64.AppImage"
-export LDAI_OUTPUT="${output_path}"
-export PATH="$(dirname "${linuxdeploy}"):${PATH}"
-export APPIMAGE_EXTRACT_AND_RUN=1
-export NO_STRIP=1
+output_path="${output_dir}/Comicviewer-${version}-x86_64.AppImage"
-# The GTK plugin assumes this optional module directory exists on every distro.
-gtk_plugin="$(command -v linuxdeploy-plugin-gtk.sh || true)"
-if [[ -n "${gtk_plugin}" ]] && [[ ! -d "$(pkg-config --variable=libdir gtk4)/gtk-4.0" ]]; then
- patched_plugin="${work_dir}/linuxdeploy-plugin-gtk.sh"
- cp "${gtk_plugin}" "${patched_plugin}"
- sed -i 's| copy_lib_tree "$gtk4_libdir" "$APPDIR/"| if [ -d "$gtk4_libdir" ]; then copy_lib_tree "$gtk4_libdir" "$APPDIR/"; fi|' "${patched_plugin}"
- chmod +x "${patched_plugin}"
- export PATH="${work_dir}:${PATH}"
+if [[ -z "${appimagetool}" ]]; then
+ linuxdeploy_path="$(command -v "${linuxdeploy}" || true)"
+ if [[ -z "${linuxdeploy_path}" ]]; then
+ printf '%s\n' "APPIMAGETOOL 또는 LINUXDEPLOY를 지정하세요." >&2
+ exit 1
+ fi
+ tool_dir="${work_dir}/appimage-tools"
+ mkdir -p "${tool_dir}"
+ (
+ cd "${tool_dir}"
+ "${linuxdeploy_path}" --appimage-extract >/dev/null
+ )
+ appimagetool="${tool_dir}/squashfs-root/plugins/linuxdeploy-plugin-appimage/usr/bin/appimagetool"
fi
-"${linuxdeploy}" \
- --appdir "${app_dir}" \
- --executable "${root_dir}/target/release/comicviewer" \
- --desktop-file "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \
- --icon-file "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \
- --custom-apprun "${root_dir}/packaging/AppRun" \
- --plugin gtk \
- --output appimage
+env ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 \
+ "${appimagetool}" "${app_dir}" "${output_path}"
echo "Created ${output_path}"
diff --git a/packaging/build-release-bundle.sh b/packaging/build-release-bundle.sh
new file mode 100755
index 0000000..10e6025
--- /dev/null
+++ b/packaging/build-release-bundle.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")"
+version="${package_id##*@}"
+artifact_name="Comicviewer-${version}-x86_64"
+bundle_name="Comicviewer-latest-x86_64"
+appimage_name="${artifact_name}.AppImage"
+appimage="${root_dir}/dist/${appimage_name}"
+checksum="${appimage}.sha256"
+archive="${root_dir}/dist/${bundle_name}.tar.gz"
+archive_checksum="${archive}.sha256"
+work_dir="$(mktemp -d)"
+bundle_dir="${work_dir}/${bundle_name}"
+archive_mtime="${SOURCE_DATE_EPOCH:-$(git -C "${root_dir}" log -1 --format=%ct)}"
+
+cleanup() {
+ rm -rf "${work_dir}"
+}
+trap cleanup EXIT
+
+if [[ ! -f "${appimage}" ]]; then
+ printf 'AppImage가 없습니다: %s\n' "${appimage}" >&2
+ printf '%s\n' "먼저 packaging/build-appimage.sh를 실행하세요." >&2
+ exit 1
+fi
+
+mkdir -p "${bundle_dir}"
+(
+ cd "${root_dir}/dist"
+ sha256sum "${appimage_name}" > "${appimage_name}.sha256"
+)
+
+install -m 0755 "${appimage}" "${bundle_dir}/${appimage_name}"
+install -m 0644 "${checksum}" "${bundle_dir}/${appimage_name}.sha256"
+install -m 0755 \
+ "${root_dir}/packaging/install-or-update.sh" \
+ "${bundle_dir}/install-or-update.sh"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.desktop" \
+ "${bundle_dir}/io.github.comicviewer.Comicviewer.desktop"
+install -m 0644 \
+ "${root_dir}/data/io.github.comicviewer.Comicviewer.svg" \
+ "${bundle_dir}/io.github.comicviewer.Comicviewer.svg"
+install -m 0644 "${root_dir}/docs/INSTALL.ko.md" "${bundle_dir}/README.ko.md"
+
+tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${archive_mtime}" \
+ -C "${work_dir}" -cf - "${bundle_name}" | gzip -n > "${archive}"
+(
+ cd "${root_dir}/dist"
+ sha256sum "$(basename "${archive}")" > "$(basename "${archive_checksum}")"
+)
+
+printf 'Created %s\n' "${archive}"
+printf 'Created %s\n' "${archive_checksum}"
diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh
new file mode 100755
index 0000000..fbc1893
--- /dev/null
+++ b/packaging/install-or-update.sh
@@ -0,0 +1,127 @@
+#!/usr/bin/env sh
+set -eu
+
+app_id="io.github.comicviewer.Comicviewer"
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+install_dir="${HOME}/.local/opt/comicviewer"
+bin_dir="${HOME}/.local/bin"
+data_home="${XDG_DATA_HOME:-${HOME}/.local/share}"
+config_home="${XDG_CONFIG_HOME:-${HOME}/.config}"
+cache_home="${XDG_CACHE_HOME:-${HOME}/.cache}"
+applications_dir="${data_home}/applications"
+icons_dir="${data_home}/icons/hicolor/scalable/apps"
+installed_appimage="${install_dir}/Comicviewer.AppImage"
+launcher="${bin_dir}/comicviewer"
+installed_desktop="${applications_dir}/${app_id}.desktop"
+installed_icon="${icons_dir}/${app_id}.svg"
+temporary_appimage=""
+temporary_desktop=""
+
+cleanup() {
+ [ -z "${temporary_appimage}" ] || rm -f "${temporary_appimage}"
+ [ -z "${temporary_desktop}" ] || rm -f "${temporary_desktop}"
+}
+trap cleanup EXIT HUP INT TERM
+
+refresh_desktop() {
+ if command -v update-desktop-database >/dev/null 2>&1; then
+ update-desktop-database "${applications_dir}" >/dev/null 2>&1 || true
+ fi
+}
+
+install_dependencies() {
+ if [ "${COMICVIEWER_SKIP_DEPENDENCIES:-0}" = "1" ]; then
+ return
+ fi
+ if ! command -v pacman >/dev/null 2>&1; then
+ printf '%s\n' "필수 패키지 자동 설치는 Arch Linux와 CachyOS만 지원합니다." >&2
+ exit 1
+ fi
+
+ set --
+ for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl gst-plugins-base \
+ gst-plugins-good gst-plugins-bad gst-libav; do
+ if ! pacman -Q "${package}" >/dev/null 2>&1; then
+ set -- "$@" "${package}"
+ fi
+ done
+ if [ "$#" -eq 0 ]; then
+ return
+ fi
+
+ printf '필수 패키지를 설치합니다: %s\n' "$*"
+ if [ "$(id -u)" -eq 0 ]; then
+ pacman -S --needed "$@"
+ else
+ if ! command -v sudo >/dev/null 2>&1; then
+ printf '%s\n' "필수 패키지를 설치하려면 sudo가 필요합니다." >&2
+ exit 1
+ fi
+ sudo pacman -S --needed "$@"
+ fi
+}
+
+if [ "${1:-}" = "--uninstall" ]; then
+ rm -f "${launcher}" "${installed_desktop}" "${installed_icon}" \
+ "${installed_appimage}"
+ rm -rf "${config_home}/comicviewer" "${data_home}/comicviewer" \
+ "${cache_home}/comicviewer"
+ rmdir "${install_dir}" 2>/dev/null || true
+ refresh_desktop
+ printf '%s\n' "Comicviewer와 설정, 읽기 기록, 캐시를 모두 제거했습니다."
+ exit 0
+fi
+
+if [ "$#" -ne 0 ]; then
+ printf '사용법: %s [--uninstall]\n' "$(basename -- "$0")" >&2
+ exit 2
+fi
+
+install_dependencies
+
+set -- "${script_dir}"/Comicviewer-*-x86_64.AppImage
+if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
+ printf '%s\n' "번들에서 Comicviewer AppImage 한 개를 찾을 수 없습니다." >&2
+ exit 1
+fi
+
+appimage="$1"
+checksum="${appimage}.sha256"
+desktop_file="${script_dir}/${app_id}.desktop"
+icon_file="${script_dir}/${app_id}.svg"
+
+for required_file in "${checksum}" "${desktop_file}" "${icon_file}"; do
+ if [ ! -f "${required_file}" ]; then
+ printf '필수 파일이 없습니다: %s\n' "$(basename -- "${required_file}")" >&2
+ exit 1
+ fi
+done
+
+(cd "${script_dir}" && sha256sum -c "$(basename -- "${checksum}")")
+
+mkdir -p "${install_dir}" "${bin_dir}" "${applications_dir}" "${icons_dir}"
+temporary_appimage="${install_dir}/.Comicviewer.AppImage.tmp.$$"
+temporary_desktop="${applications_dir}/.${app_id}.desktop.tmp.$$"
+
+install -m 0755 "${appimage}" "${temporary_appimage}"
+mv -f "${temporary_appimage}" "${installed_appimage}"
+temporary_appimage=""
+ln -sfn "${installed_appimage}" "${launcher}"
+while IFS= read -r line || [ -n "${line}" ]; do
+ case "${line}" in
+ Exec=*) printf 'Exec="%s" %%f\n' "${launcher}" ;;
+ *) printf '%s\n' "${line}" ;;
+ esac
+done < "${desktop_file}" > "${temporary_desktop}"
+chmod 0644 "${temporary_desktop}"
+mv -f "${temporary_desktop}" "${installed_desktop}"
+temporary_desktop=""
+install -m 0644 "${icon_file}" "${installed_icon}"
+refresh_desktop
+
+if ! command -v gio >/dev/null 2>&1; then
+ printf '%s\n' "주의: 원격 파일 접근을 위해 gvfs와 gvfs-smb를 설치하세요." >&2
+fi
+
+printf '%s\n' "Comicviewer 설치 또는 업데이트를 완료했습니다."
+printf '실행: %s\n' "${launcher}"
diff --git a/src/archive.rs b/src/archive.rs
index 04e6090..eb7f661 100644
--- a/src/archive.rs
+++ b/src/archive.rs
@@ -1,16 +1,23 @@
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;
+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,10 +28,16 @@ pub struct ArchivePage {
pub struct DecodedPage {
pub width: u32,
pub height: u32,
- pub rgba: Vec,
+ pub frames: Vec,
+ pub loop_count: Option,
}
-pub fn list_pages(path: &Path) -> Result> {
+pub struct DecodedFrame {
+ pub rgba: Vec,
+ pub delay: Duration,
+}
+
+pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> {
let file = File::open(path)
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
let mut archive = ZipArchive::new(file)
@@ -41,7 +54,7 @@ pub fn list_pages(path: &Path) -> Result> {
}
}
- pages.sort_by(|left, right| natural_cmp(&left.name, &right.name));
+ pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive));
Ok(pages)
}
@@ -70,25 +83,123 @@ pub fn decode_page(path: &Path, page: &ArchivePage) -> Result {
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
}
- let reader = ImageReader::new(Cursor::new(&encoded))
+ decode_image_bytes(&encoded, &page.name)
+}
+
+pub fn decode_image_file(path: &Path, display_name: &str) -> Result {
+ let file = File::open(path)
+ .with_context(|| format!("이미지 파일을 열 수 없습니다: {display_name}"))?;
+ let mut encoded = Vec::new();
+ file.take(MAX_ENTRY_SIZE + 1)
+ .read_to_end(&mut encoded)
+ .with_context(|| format!("이미지 파일을 읽을 수 없습니다: {display_name}"))?;
+ if encoded.len() as u64 > MAX_ENTRY_SIZE {
+ bail!("이미지 크기가 제한을 초과합니다: {display_name} (최대 256MB)");
+ }
+ decode_image_bytes(&encoded, display_name)
+}
+
+fn decode_image_bytes(encoded: &[u8], name: &str) -> Result {
+ let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format()
- .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
+ .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
+ let format = reader.format();
let (width, height) = reader
.into_dimensions()
- .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
- validate_dimensions(width, height, &page.name)?;
+ .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
+ validate_dimensions(width, height, name)?;
- let image = image::load_from_memory(&encoded)
- .with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.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();
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- >,
+ loop_count: Option,
+ name: &str,
+) -> Result {
+ 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 {
+ 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
@@ -161,7 +272,7 @@ mod tests {
archive.write_all(b"ignored").unwrap();
archive.finish().unwrap();
- let pages = list_pages(&path).unwrap();
+ let pages = list_pages(&path, true).unwrap();
assert_eq!(
pages
.iter()
@@ -171,8 +282,51 @@ 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();
}
+
+ #[test]
+ fn decodes_a_standalone_image() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("photo.png");
+ let image = image::RgbaImage::from_pixel(4, 5, image::Rgba([1, 2, 3, 255]));
+ image.save(&path).unwrap();
+
+ let decoded = decode_image_file(&path, "photo.png").unwrap();
+ assert_eq!((decoded.width, decoded.height), (4, 5));
+ 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);
+ }
}
diff --git a/src/browser.rs b/src/browser.rs
index 1613747..65b5d8a 100644
--- a/src/browser.rs
+++ b/src/browser.rs
@@ -6,12 +6,22 @@ use std::time::SystemTime;
pub enum EntryKind {
Directory,
ZipArchive,
+ Image,
+ Video,
+ OtherFile,
+}
+
+impl EntryKind {
+ pub fn is_viewable(self) -> bool {
+ matches!(self, Self::ZipArchive | Self::Image | Self::Video)
+ }
}
#[derive(Debug, Clone)]
pub struct BrowserEntry {
pub uri: String,
pub name: String,
+ pub list_name: Option,
pub kind: EntryKind,
pub size: Option,
pub modified: Option,
@@ -19,6 +29,12 @@ pub struct BrowserEntry {
pub etag: Option,
}
+impl BrowserEntry {
+ pub fn display_name(&self) -> &str {
+ self.list_name.as_deref().unwrap_or(&self.name)
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortField {
Name,
@@ -32,11 +48,19 @@ pub enum SortDirection {
Descending,
}
-pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
+pub fn sort_entries(
+ entries: &mut [BrowserEntry],
+ field: SortField,
+ direction: SortDirection,
+ case_insensitive: bool,
+) {
entries.sort_by(|left, right| {
- let kind_order = match (left.kind, right.kind) {
- (EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less,
- (EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater,
+ let kind_order = match (
+ left.kind == EntryKind::Directory,
+ right.kind == EntryKind::Directory,
+ ) {
+ (true, false) => Ordering::Less,
+ (false, true) => Ordering::Greater,
_ => Ordering::Equal,
};
if kind_order != Ordering::Equal {
@@ -44,11 +68,32 @@ pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: S
}
match field {
- SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction),
+ SortField::Name => apply_direction(
+ natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive),
+ direction,
+ ),
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
- .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
+ .then_with(|| {
+ apply_direction(
+ natural_cmp_with_case(
+ left.display_name(),
+ right.display_name(),
+ case_insensitive,
+ ),
+ direction,
+ )
+ }),
SortField::Created => compare_optional_time(left.created, right.created, direction)
- .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
+ .then_with(|| {
+ apply_direction(
+ natural_cmp_with_case(
+ left.display_name(),
+ right.display_name(),
+ case_insensitive,
+ ),
+ direction,
+ )
+ }),
}
});
}
@@ -73,19 +118,28 @@ fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
}
}
-pub fn is_zip_path(path: &Path) -> bool {
- path.extension()
+pub fn classify_file(path: &Path) -> EntryKind {
+ let extension = path
+ .extension()
.and_then(|extension| extension.to_str())
- .is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
+ .map(str::to_ascii_lowercase);
+ match extension.as_deref() {
+ Some("zip") => EntryKind::ZipArchive,
+ Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif") => EntryKind::Image,
+ Some("mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "mpeg" | "mpg" | "ogv") => {
+ EntryKind::Video
+ }
+ _ => EntryKind::OtherFile,
+ }
}
-pub fn natural_cmp(left: &str, right: &str) -> Ordering {
+pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering {
let mut left_chars = left.chars().peekable();
let mut right_chars = right.chars().peekable();
loop {
match (left_chars.peek(), right_chars.peek()) {
- (None, None) => return Ordering::Equal,
+ (None, None) => return left.cmp(right),
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(left_char), Some(right_char))
@@ -118,10 +172,11 @@ pub fn natural_cmp(left: &str, right: &str) -> Ordering {
(Some(_), Some(_)) => {
let left_char = left_chars.next().unwrap();
let right_char = right_chars.next().unwrap();
- let order = left_char
- .to_lowercase()
- .cmp(right_char.to_lowercase())
- .then_with(|| left_char.cmp(&right_char));
+ let order = if case_insensitive {
+ left_char.to_lowercase().cmp(right_char.to_lowercase())
+ } else {
+ left_char.cmp(&right_char)
+ };
if order != Ordering::Equal {
return order;
}
@@ -148,22 +203,67 @@ mod tests {
#[test]
fn natural_order_places_two_before_ten() {
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
- names.sort_by(|left, right| natural_cmp(left, right));
+ names.sort_by(|left, right| natural_cmp_with_case(left, right, true));
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
}
#[test]
fn natural_order_handles_numbers_larger_than_integers() {
assert_eq!(
- natural_cmp("9.zip", "123456789012345678901234567890.zip"),
+ natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true),
Ordering::Less
);
}
+ #[test]
+ fn natural_order_can_ignore_or_respect_case() {
+ assert_eq!(
+ natural_cmp_with_case("a2.jpg", "A10.jpg", true),
+ Ordering::Less
+ );
+ assert_eq!(
+ natural_cmp_with_case("a2.jpg", "A10.jpg", false),
+ Ordering::Greater
+ );
+ }
+
+ #[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]
fn zip_extension_is_case_insensitive() {
- assert!(is_zip_path(Path::new("comic.ZIP")));
- assert!(!is_zip_path(Path::new("comic.cbz")));
+ assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
+ assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive);
+ }
+
+ #[test]
+ fn classifies_common_media_extensions_case_insensitively() {
+ assert_eq!(classify_file(Path::new("photo.JPG")), EntryKind::Image);
+ assert_eq!(classify_file(Path::new("animation.webp")), EntryKind::Image);
+ 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);
}
#[test]
diff --git a/src/config.rs b/src/config.rs
index 0471dd2..905b382 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -10,7 +10,12 @@ use serde::{Deserialize, Serialize};
pub struct AppConfig {
pub cache_quota_gb: u64,
pub fast_image_loading: bool,
+ pub case_insensitive_sort: bool,
+ pub viewer_mode: ViewerMode,
+ pub two_page_right_to_left: bool,
pub locations: Vec,
+ pub favorites: Vec,
+ pub recent_files: Vec,
}
impl Default for AppConfig {
@@ -18,7 +23,12 @@ impl Default for AppConfig {
Self {
cache_quota_gb: 10,
fast_image_loading: true,
+ case_insensitive_sort: true,
+ viewer_mode: ViewerMode::FitWidth,
+ two_page_right_to_left: true,
locations: Vec::new(),
+ favorites: Vec::new(),
+ recent_files: Vec::new(),
}
}
}
@@ -36,6 +46,21 @@ pub struct LocationProfile {
pub domain: String,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FavoriteEntry {
+ pub name: String,
+ pub uri: String,
+ pub is_directory: bool,
+ 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)]
#[serde(rename_all = "lowercase")]
pub enum LocationKind {
@@ -45,6 +70,39 @@ pub enum LocationKind {
Sftp,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum ViewerMode {
+ ActualSize,
+ #[default]
+ FitWidth,
+ FitHeight,
+ TwoPage,
+ Continuous,
+}
+
+impl ViewerMode {
+ pub fn dropdown_index(self) -> u32 {
+ match self {
+ Self::ActualSize => 0,
+ Self::FitWidth => 1,
+ Self::FitHeight => 2,
+ Self::TwoPage => 3,
+ Self::Continuous => 4,
+ }
+ }
+
+ pub fn from_dropdown_index(index: u32) -> Self {
+ match index {
+ 0 => Self::ActualSize,
+ 2 => Self::FitHeight,
+ 3 => Self::TwoPage,
+ 4 => Self::Continuous,
+ _ => Self::FitWidth,
+ }
+ }
+}
+
impl AppConfig {
pub fn load_or_create(path: &Path) -> Result {
if path.exists() {
@@ -122,6 +180,7 @@ mod tests {
#[test]
fn default_cache_quota_is_ten_gigabytes() {
assert_eq!(AppConfig::default().cache_quota_gb, 10);
+ assert!(AppConfig::default().case_insensitive_sort);
}
#[test]
@@ -129,6 +188,9 @@ mod tests {
let config = AppConfig {
cache_quota_gb: 0,
fast_image_loading: true,
+ case_insensitive_sort: true,
+ viewer_mode: ViewerMode::FitWidth,
+ two_page_right_to_left: true,
locations: vec![LocationProfile {
name: "테스트".into(),
kind: LocationKind::Sftp,
@@ -137,16 +199,77 @@ mod tests {
password: "plain-password".into(),
domain: String::new(),
}],
+ favorites: vec![FavoriteEntry {
+ name: "원격 만화".into(),
+ uri: "sftp://example.invalid/comics/book.zip".into(),
+ is_directory: false,
+ profile: LocationProfile {
+ name: "즐겨찾기 서버".into(),
+ kind: LocationKind::Sftp,
+ address: "sftp://example.invalid/comics".into(),
+ username: "favorite-reader".into(),
+ password: "favorite-password".into(),
+ 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 decoded: AppConfig = toml::from_str(&encoded).unwrap();
assert_eq!(decoded.locations[0].password, "plain-password");
+ assert_eq!(decoded.favorites[0].profile.username, "favorite-reader");
+ 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]
fn fast_image_loading_is_enabled_for_existing_configs() {
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
assert!(decoded.fast_image_loading);
+ assert!(decoded.case_insensitive_sort);
+ assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
+ assert!(decoded.two_page_right_to_left);
+ assert!(decoded.favorites.is_empty());
+ assert!(decoded.recent_files.is_empty());
+ }
+
+ #[test]
+ fn viewer_modes_round_trip_through_dropdown_indices() {
+ for mode in [
+ ViewerMode::ActualSize,
+ ViewerMode::FitWidth,
+ ViewerMode::FitHeight,
+ ViewerMode::TwoPage,
+ ViewerMode::Continuous,
+ ] {
+ assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode);
+ }
+ 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);
}
}
diff --git a/src/main.rs b/src/main.rs
index f48b1a6..dccd524 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,36 +8,86 @@ mod viewer;
mod window;
mod zip_cache;
+use std::cell::RefCell;
+use std::rc::Rc;
+
use anyhow::Result;
+use gtk::gio;
use gtk::prelude::*;
const APP_ID: &str = "io.github.comicviewer.Comicviewer";
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| {
- if let Err(error) = activate(application) {
- eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}");
- application.quit();
+ let activate_state = Rc::clone(&state);
+ application.connect_activate(move |application| {
+ match activate_state.ensure_main_window(application) {
+ 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();
}
-fn activate(application: >k::Application) -> Result<()> {
- let app_paths = paths::AppPaths::discover()?;
- let config = config::AppConfig::load_or_create(&app_paths.config_file)?;
- let database = database::Database::open(&app_paths.database_file)?;
- let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?;
- let window = window::ComicWindow::new(
- application,
- config,
- app_paths.config_file,
- database,
- zip_cache,
- )?;
- window.present();
- Ok(())
+#[derive(Default)]
+struct ApplicationState {
+ main_window: RefCell