Compare commits

..

No commits in common. "main" and "v0.1.2" have entirely different histories.
main ... v0.1.2

21 changed files with 540 additions and 7104 deletions

1
.gitignore vendored
View file

@ -9,4 +9,3 @@
/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 ZIP archives, It browses local, SMB, FTP, and SFTP locations and opens image files stored in
standalone images, and videos. ZIP archives.
## 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 and reading history - SQLite for cached directory metadata, search indexes, 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,46 +20,20 @@ standalone images, and videos.
- 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.
- Evict the oldest unpinned ZIP cache files before a download needs space, at - Support JPEG, PNG, WebP, static GIF, BMP, and AVIF images in ZIP archives.
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.
- Filter the currently displayed browser directory by filename and restore all - Search folder and ZIP names recursively below the current location using the
entries when the search text is cleared. cached index.
- 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. Persist the last selected that advance by one page to adjust page pairing.
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
@ -76,23 +50,11 @@ 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, image dimensions, animation frame counts, and - Validate archive entry sizes and image dimensions before allocation.
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.
@ -112,13 +74,8 @@ cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features cargo test --all-features
``` ```
Add focused tests for natural sorting, oldest-first cache eviction, pin and Add focused tests for natural sorting, cache eviction, metadata refresh,
temporary-file protection, metadata refresh, archive filtering, animated image archive filtering, reading progress, and remote file change detection.
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,19 +182,17 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "comicviewer" name = "comicviewer"
version = "0.2.20" version = "0.1.2"
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",
@ -1213,17 +1211,6 @@ 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"
@ -1488,16 +1475,6 @@ 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.20" version = "0.1.2"
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,16 +8,14 @@ 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_12"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] }
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

@ -1,7 +1,7 @@
# Comicviewer # Comicviewer
Comicviewer is a GTK4 desktop application for browsing ZIP archives, images, Comicviewer is a GTK4 desktop application for browsing and reading comic image
and videos stored locally or on SMB, FTP, and SFTP servers. archives stored locally or on SMB, FTP, and SFTP servers.
한국어 문서: 한국어 문서:
@ -11,16 +11,15 @@ and videos stored locally or on SMB, FTP, and SFTP servers.
The project is currently in early development. It supports saved local, SMB, The project is currently in early development. It supports saved local, SMB,
FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache, FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache,
natural sorting, safe on-demand image decoding and prefetching, single-page and natural sorting, safe on-demand image decoding and prefetching, single-page and
two-page viewing, continuous vertical scrolling, standalone image and video two-page viewing, continuous vertical scrolling, reading progress, and
playback, reading progress, and XDG-compliant configuration. XDG-compliant configuration.
## Development dependencies ## Development dependencies
On CachyOS or Arch Linux: On CachyOS or Arch Linux:
```sh ```sh
sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb gstreamer \ sudo pacman -S rust gtk4 sqlite gvfs gvfs-smb
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
``` ```
Run the application with: Run the application with:
@ -29,17 +28,16 @@ Run the application with:
cargo run cargo run
``` ```
The AppImage uses the host's GTK4 and dav1d libraries. GVfs provides the FTP GVfs provides the FTP and SFTP backends. `gvfs-smb` adds SMB support. These
and SFTP backends, while `gvfs-smb` adds SMB support. These packages must be services must be installed on the host even when Comicviewer is distributed as
installed on the host. an AppImage.
Application files are stored below the standard XDG configuration, data, and Application files are stored below the standard XDG configuration, data, and
cache directories. No runtime state is written into the source tree. cache directories. No runtime state is written into the source tree.
## AppImage ## AppImage
The AppImage build uses `appimagetool`. The script can extract appimagetool The AppImage build uses `linuxdeploy` and `linuxdeploy-plugin-gtk`:
from a linuxdeploy AppImage when it is not provided directly:
```sh ```sh
PATH="/path/to/appimage-tools:$PATH" \ PATH="/path/to/appimage-tools:$PATH" \

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 %f Exec=comicviewer
Icon=io.github.comicviewer.Comicviewer Icon=io.github.comicviewer.Comicviewer
Terminal=false Terminal=false
Categories=Graphics;Viewer; Categories=Graphics;Viewer;
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; MimeType=application/zip;application/vnd.comicbook+zip;
StartupNotify=true StartupNotify=true

View file

@ -26,29 +26,6 @@
</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.0" date="2026-07-21" />
<release version="0.1.4" date="2026-07-21" />
<release version="0.1.3" date="2026-07-21" />
<release version="0.1.2" date="2026-07-21" /> <release version="0.1.2" date="2026-07-21" />
<release version="0.1.1" date="2026-07-21" /> <release version="0.1.1" date="2026-07-21" />
<release version="0.1.0" date="2026-07-21" /> <release version="0.1.0" date="2026-07-21" />

View file

@ -1,49 +1,40 @@
# Comicviewer 설치 및 실행 안내 # Comicviewer 설치 및 실행 안내
Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디어 뷰어입니다. Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰어입니다.
로컬 및 SMB, FTP, SFTP 위치의 ZIP, 이미지, 동영상 파일을 열 수 있습니다. 로컬 파일뿐 아니라 SMB, FTP, SFTP 서버의 폴더와 ZIP 파일도 열 수 있습니다.
## 1. 필수 패키지 ## 1. 필수 패키지 설치
통합 번들의 설치 스크립트가 다음 필수 패키지 중 누락된 항목을 자동으로 AppImage 실행과 원격 파일 접근에 필요한 패키지를 설치합니다.
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
```sh ```sh
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \ sudo pacman -S fuse2 gvfs gvfs-smb
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
``` ```
- `fuse2`: AppImage 마운트 및 실행 - `fuse2`: AppImage 마운트 및 실행
- `gtk4` 4.12 이상, `dav1d`: 화면 표시와 이미지 디코딩
- `gvfs`: FTP와 SFTP 접근 - `gvfs`: FTP와 SFTP 접근
- `gvfs-smb`: SMB 공유 접근 - `gvfs-smb`: SMB 공유 접근
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
- `curl`: About 창에서 최신 업데이트 다운로드
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
다음 두 파일을 같은 폴더에 다운로드합니다. Release 페이지에서 `v0.1.2` 최신 Release를 열고 다음 파일을 모두 같은 폴더에
다운로드합니다. 이 Git 서버에서는 대용량 파일 전송이 손상될 수 있어 통합
번들을 8MiB 단위로 나누어 제공합니다.
- [Comicviewer-latest-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz) - `Comicviewer-0.1.2-x86_64-install.sh`
- [Comicviewer-latest-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256) - `Comicviewer-0.1.2-x86_64.tar.gz.sha256`
- `Comicviewer-0.1.2-x86_64.tar.gz.parts.sha256`
- 이름이 `Comicviewer-0.1.2-x86_64.tar.gz.part-`로 시작하는 모든 파일
터미널에서 직접 다운로드할 수도 있습니다. 다운로드 폴더에서 설치 스크립트에 실행 권한을 부여하고 실행합니다.
```sh ```sh
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz chmod +x Comicviewer-0.1.2-x86_64-install.sh
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256 ./Comicviewer-0.1.2-x86_64-install.sh
``` ```
체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다. 스크립트는 각 분할 파일과 조립한 통합 번들, AppImage의 체크섬을 차례로
검증하고 다음 작업을 수행합니다.
```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`에 설치 - AppImage를 `~/.local/opt/comicviewer/Comicviewer.AppImage`에 설치
- `~/.local/bin/comicviewer` 실행 링크 생성 - `~/.local/bin/comicviewer` 실행 링크 생성
@ -56,13 +47,65 @@ cd Comicviewer-latest-x86_64
제거하려면 압축을 푼 번들 폴더에서 다음을 실행합니다. 제거하려면 압축을 푼 번들 폴더에서 다음을 실행합니다.
```sh ```sh
./install-or-update.sh --uninstall ./Comicviewer-0.1.2-x86_64-install.sh --uninstall
``` ```
이 명령은 프로그램과 함께 저장한 위치 및 인증정보, 읽기 기록, 원격 ZIP 캐시를 이 명령은 프로그램과 함께 저장한 위치 및 인증정보, 읽기 기록, 원격 ZIP 캐시를
모두 삭제합니다. 삭제한 데이터는 복구할 수 없으므로 필요한 설정은 제거 전에 모두 삭제합니다. 삭제한 데이터는 복구할 수 없으므로 필요한 설정은 제거 전에
별도로 백업합니다. 별도로 백업합니다.
## 3. AppImage만 다운로드하는 방법
Release 페이지에서 다음 두 파일을 같은 폴더에 다운로드합니다.
- `Comicviewer-0.1.2-x86_64.AppImage`
- `Comicviewer-0.1.2-x86_64.AppImage.sha256`
다운로드한 폴더에서 체크섬을 검증합니다.
```sh
sha256sum -c Comicviewer-0.1.2-x86_64.AppImage.sha256
```
`성공` 또는 `OK`가 표시되어야 합니다. 검증에 실패하면 파일을 실행하지 말고
다시 다운로드합니다.
## 4. 직접 실행
AppImage에 실행 권한을 부여한 뒤 실행합니다.
```sh
chmod +x Comicviewer-0.1.2-x86_64.AppImage
./Comicviewer-0.1.2-x86_64.AppImage
```
FUSE를 사용할 수 없는 환경에서는 다음과 같이 임시 추출 방식으로 실행할 수
있습니다.
```sh
./Comicviewer-0.1.2-x86_64.AppImage --appimage-extract-and-run
```
## 5. 선택 사항: 수동으로 사용자 계정에 설치
AppImage를 사용자 전용 프로그램 폴더에 설치하고 명령어로 실행하려면 다음을
사용합니다.
```sh
mkdir -p ~/.local/opt/comicviewer ~/.local/bin
install -m 755 Comicviewer-0.1.2-x86_64.AppImage \
~/.local/opt/comicviewer/Comicviewer.AppImage
ln -sfn ~/.local/opt/comicviewer/Comicviewer.AppImage ~/.local/bin/comicviewer
```
이후 새 터미널에서 다음 명령으로 실행할 수 있습니다.
```sh
comicviewer
```
`~/.local/bin`이 `PATH`에 없으면 AppImage의 전체 경로로 실행합니다.
## 데이터 저장 위치 ## 데이터 저장 위치
Comicviewer는 XDG 표준 경로를 사용합니다. Comicviewer는 XDG 표준 경로를 사용합니다.
@ -80,32 +123,4 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다. - SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다.
- FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다. - FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다.
- 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다. - 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다.
- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다. - 기본 영구 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 내부 이미지에서 재생됩니다.

View file

@ -7,9 +7,10 @@ Release 첨부 파일로 제공하는 방식을 권장합니다. 버전별 바
기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이 기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이
절차를 사용합니다. 절차를 사용합니다.
태그, 프로젝트 버전, AppImage의 내부 버전은 반드시 같아야 합니다. 사용자가 태그, 프로젝트 버전, AppImage, 통합 번들의 버전은 반드시 같아야 합니다.
받는 통합 번들은 버전과 관계없이 `Comicviewer-latest-x86_64.tar.gz`라는 고정 예를 들어 `v0.1.2` Release에는 이름에 `0.1.2`가 포함된 파일만 첨부합니다.
이름을 사용합니다. Git 태그만 푸시하면 첨부 파일은 자동으로 등록되지 않으므로, 마지막 단계에서
Release를 만들고 파일 네 개를 직접 첨부해야 합니다.
## 1. 버전 갱신 ## 1. 버전 갱신
@ -31,9 +32,8 @@ cargo test --all-features
## 3. AppImage 빌드 ## 3. AppImage 빌드
`appimagetool`을 직접 지정하거나, appimagetool을 포함한 `linuxdeploy-x86_64.AppImage`와 `linuxdeploy-plugin-gtk.sh`가 있는 도구
`linuxdeploy-x86_64.AppImage`를 지정합니다. AppImage는 Arch/CachyOS 호스트의 폴더를 지정합니다.
GTK4와 dav1d를 사용하며 시스템 라이브러리를 내부에 복사하지 않습니다.
```sh ```sh
PATH="/path/to/appimage-tools:$PATH" \ PATH="/path/to/appimage-tools:$PATH" \
@ -55,9 +55,12 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
- `dist/Comicviewer-0.2.20-x86_64.AppImage.sha256` - `dist/Comicviewer-0.1.2-x86_64.AppImage.sha256`
- `dist/Comicviewer-latest-x86_64.tar.gz` - `dist/Comicviewer-0.1.2-x86_64.tar.gz`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256` - `dist/Comicviewer-0.1.2-x86_64.tar.gz.sha256`
- `dist/Comicviewer-0.1.2-x86_64.tar.gz.parts.sha256`
- `dist/Comicviewer-0.1.2-x86_64.tar.gz.part-*`
- `dist/Comicviewer-0.1.2-x86_64-install.sh`
통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘, 통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘,
한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를 한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를
@ -66,9 +69,10 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
생성된 번들의 압축 목록과 체크섬을 확인합니다. 생성된 번들의 압축 목록과 체크섬을 확인합니다.
```sh ```sh
tar -tzf dist/Comicviewer-latest-x86_64.tar.gz tar -tzf dist/Comicviewer-0.1.2-x86_64.tar.gz
cd dist cd dist
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 sha256sum -c Comicviewer-0.1.2-x86_64.tar.gz.sha256
sha256sum -c Comicviewer-0.1.2-x86_64.tar.gz.parts.sha256
``` ```
## 5. 소스 커밋과 태그 ## 5. 소스 커밋과 태그
@ -78,44 +82,39 @@ 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 "Release v0.2.20" git commit -m "Prepare v0.1.2 release"
git tag -a v0.2.20 -m "Comicviewer v0.2.20" git tag -a v0.1.2 -m "Comicviewer v0.1.2"
git push origin main git push origin main
git push origin v0.2.20 git push origin v0.1.2
``` ```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
패치 버전을 만듭니다. 패치 버전을 만듭니다.
## 6. Cafe24 배포 ## 6. Git 서버 Release 생성
Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을 Git 서버의 Releases 화면에서 푸시한 태그를 선택해 Release를 생성하고 다음
`/www/Downloads/Comicviewer/`에 업로드합니다. 파일을 첨부합니다.
- `Comicviewer-latest-x86_64.tar.gz` - `Comicviewer-0.1.2-x86_64-install.sh`
- `Comicviewer-latest-x86_64.tar.gz.sha256` - `Comicviewer-0.1.2-x86_64.tar.gz.sha256`
- `Comicviewer-0.1.2-x86_64.tar.gz.parts.sha256`
- `Comicviewer-0.1.2-x86_64.tar.gz.part-*` 전체
업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과 이 서버에서는 대용량 단일 HTTP 다운로드가 손상될 수 있으므로 AppImage와
실제 설치를 확인합니다. 통합 `tar.gz` 원본은 직접 첨부하지 않습니다. 각 분할 파일은 8MiB 이하이며,
업로드 후 반드시 원격에서 다시 내려받아 체크섬을 확인합니다.
```text Release 설명에는 주요 변경 사항과 함께 다음 요구사항을 안내합니다.
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 ```text
지원 환경: CachyOS / Arch Linux x86_64 지원 환경: CachyOS / Arch Linux x86_64
필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb, gstreamer, 필수 패키지: fuse2, gvfs, gvfs-smb
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 chmod +x Comicviewer-0.1.2-x86_64-install.sh
tar -xzf Comicviewer-latest-x86_64.tar.gz ./Comicviewer-0.1.2-x86_64-install.sh
cd Comicviewer-latest-x86_64
./install-or-update.sh
``` ```
사용자에게는 [`INSTALL.ko.md`](INSTALL.ko.md) 문서를 함께 안내합니다. 사용자에게는 [`INSTALL.ko.md`](INSTALL.ko.md) 문서를 함께 안내합니다.

View file

@ -3,7 +3,6 @@ set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
linuxdeploy="${LINUXDEPLOY:-linuxdeploy-x86_64.AppImage}" linuxdeploy="${LINUXDEPLOY:-linuxdeploy-x86_64.AppImage}"
appimagetool="${APPIMAGETOOL:-}"
output_dir="${OUTPUT_DIR:-${root_dir}/dist}" output_dir="${OUTPUT_DIR:-${root_dir}/dist}"
package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")" package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")"
version="${package_id##*@}" version="${package_id##*@}"
@ -17,47 +16,34 @@ trap cleanup EXIT
cargo build --manifest-path "${root_dir}/Cargo.toml" --release cargo build --manifest-path "${root_dir}/Cargo.toml" --release
mkdir -p "${output_dir}" mkdir -p "${output_dir}"
mkdir -p \ mkdir -p "${app_dir}/usr/share/metainfo"
"${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 \ install -m 0644 \
"${root_dir}/data/io.github.comicviewer.Comicviewer.metainfo.xml" \ "${root_dir}/data/io.github.comicviewer.Comicviewer.metainfo.xml" \
"${app_dir}/usr/share/metainfo/io.github.comicviewer.Comicviewer.appdata.xml" "${app_dir}/usr/share/metainfo/io.github.comicviewer.Comicviewer.appdata.xml"
output_path="${output_dir}/Comicviewer-${version}-x86_64.AppImage" output_path="${output_dir}/Comicviewer-${version}-x86_64.AppImage"
export LDAI_OUTPUT="${output_path}"
export PATH="$(dirname "${linuxdeploy}"):${PATH}"
export APPIMAGE_EXTRACT_AND_RUN=1
export NO_STRIP=1
if [[ -z "${appimagetool}" ]]; then # The GTK plugin assumes this optional module directory exists on every distro.
linuxdeploy_path="$(command -v "${linuxdeploy}" || true)" gtk_plugin="$(command -v linuxdeploy-plugin-gtk.sh || true)"
if [[ -z "${linuxdeploy_path}" ]]; then if [[ -n "${gtk_plugin}" ]] && [[ ! -d "$(pkg-config --variable=libdir gtk4)/gtk-4.0" ]]; then
printf '%s\n' "APPIMAGETOOL 또는 LINUXDEPLOY를 지정하세요." >&2 patched_plugin="${work_dir}/linuxdeploy-plugin-gtk.sh"
exit 1 cp "${gtk_plugin}" "${patched_plugin}"
fi sed -i 's| copy_lib_tree "$gtk4_libdir" "$APPDIR/"| if [ -d "$gtk4_libdir" ]; then copy_lib_tree "$gtk4_libdir" "$APPDIR/"; fi|' "${patched_plugin}"
tool_dir="${work_dir}/appimage-tools" chmod +x "${patched_plugin}"
mkdir -p "${tool_dir}" export PATH="${work_dir}:${PATH}"
(
cd "${tool_dir}"
"${linuxdeploy_path}" --appimage-extract >/dev/null
)
appimagetool="${tool_dir}/squashfs-root/plugins/linuxdeploy-plugin-appimage/usr/bin/appimagetool"
fi fi
env ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 \ "${linuxdeploy}" \
"${appimagetool}" "${app_dir}" "${output_path}" --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
echo "Created ${output_path}" echo "Created ${output_path}"

View file

@ -4,13 +4,14 @@ set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")" package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")"
version="${package_id##*@}" version="${package_id##*@}"
artifact_name="Comicviewer-${version}-x86_64" bundle_name="Comicviewer-${version}-x86_64"
bundle_name="Comicviewer-latest-x86_64" appimage_name="${bundle_name}.AppImage"
appimage_name="${artifact_name}.AppImage"
appimage="${root_dir}/dist/${appimage_name}" appimage="${root_dir}/dist/${appimage_name}"
checksum="${appimage}.sha256" checksum="${appimage}.sha256"
archive="${root_dir}/dist/${bundle_name}.tar.gz" archive="${root_dir}/dist/${bundle_name}.tar.gz"
archive_checksum="${archive}.sha256" archive_checksum="${archive}.sha256"
parts_checksum="${archive}.parts.sha256"
split_installer="${root_dir}/dist/${bundle_name}-install.sh"
work_dir="$(mktemp -d)" work_dir="$(mktemp -d)"
bundle_dir="${work_dir}/${bundle_name}" bundle_dir="${work_dir}/${bundle_name}"
archive_mtime="${SOURCE_DATE_EPOCH:-$(git -C "${root_dir}" log -1 --format=%ct)}" archive_mtime="${SOURCE_DATE_EPOCH:-$(git -C "${root_dir}" log -1 --format=%ct)}"
@ -50,7 +51,14 @@ tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${archive_mtime}"
( (
cd "${root_dir}/dist" cd "${root_dir}/dist"
sha256sum "$(basename "${archive}")" > "$(basename "${archive_checksum}")" sha256sum "$(basename "${archive}")" > "$(basename "${archive_checksum}")"
rm -f "$(basename "${archive}").part-"* "$(basename "${parts_checksum}")"
split --bytes=8M --numeric-suffixes=0 --suffix-length=2 \
"$(basename "${archive}")" "$(basename "${archive}").part-"
sha256sum "$(basename "${archive}").part-"* > "$(basename "${parts_checksum}")"
) )
install -m 0755 "${root_dir}/packaging/install-split-release.sh" "${split_installer}"
printf 'Created %s\n' "${archive}" printf 'Created %s\n' "${archive}"
printf 'Created %s\n' "${archive_checksum}" printf 'Created %s\n' "${archive_checksum}"
printf 'Created %s and split parts\n' "${parts_checksum}"
printf 'Created %s\n' "${split_installer}"

View file

@ -29,38 +29,6 @@ refresh_desktop() {
fi 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 if [ "${1:-}" = "--uninstall" ]; then
rm -f "${launcher}" "${installed_desktop}" "${installed_icon}" \ rm -f "${launcher}" "${installed_desktop}" "${installed_icon}" \
"${installed_appimage}" "${installed_appimage}"
@ -77,8 +45,6 @@ if [ "$#" -ne 0 ]; then
exit 2 exit 2
fi fi
install_dependencies
set -- "${script_dir}"/Comicviewer-*-x86_64.AppImage set -- "${script_dir}"/Comicviewer-*-x86_64.AppImage
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
printf '%s\n' "번들에서 Comicviewer AppImage 한 개를 찾을 수 없습니다." >&2 printf '%s\n' "번들에서 Comicviewer AppImage 한 개를 찾을 수 없습니다." >&2
@ -109,7 +75,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" %%f\n' "${launcher}" ;; Exec=*) printf 'Exec="%s"\n' "${launcher}" ;;
*) printf '%s\n' "${line}" ;; *) printf '%s\n' "${line}" ;;
esac esac
done < "${desktop_file}" > "${temporary_desktop}" done < "${desktop_file}" > "${temporary_desktop}"

View file

@ -0,0 +1,70 @@
#!/usr/bin/env sh
set -eu
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
temporary_archive=""
extract_dir=""
case "$#:${1:-}" in
0:) action="" ;;
1:--uninstall) action="--uninstall" ;;
*)
printf '사용법: %s [--uninstall]\n' "$(basename -- "$0")" >&2
exit 2
;;
esac
cleanup() {
[ -z "${temporary_archive}" ] || rm -f "${temporary_archive}"
[ -z "${extract_dir}" ] || rm -rf "${extract_dir}"
}
trap cleanup EXIT HUP INT TERM
set -- "${script_dir}"/Comicviewer-*-x86_64.tar.gz.parts.sha256
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
printf '%s\n' "분할 파일 체크섬 목록 한 개를 찾을 수 없습니다." >&2
exit 1
fi
parts_checksum="$1"
archive_name="$(basename -- "${parts_checksum%.parts.sha256}")"
archive_checksum="${script_dir}/${archive_name}.sha256"
if [ ! -f "${archive_checksum}" ]; then
printf '필수 파일이 없습니다: %s\n' "$(basename -- "${archive_checksum}")" >&2
exit 1
fi
set -- "${script_dir}/${archive_name}.part-"*
if [ ! -f "$1" ]; then
printf '%s\n' "분할 번들 파일을 찾을 수 없습니다." >&2
exit 1
fi
(cd "${script_dir}" && sha256sum -c "$(basename -- "${parts_checksum}")")
temporary_archive="$(mktemp "${TMPDIR:-/tmp}/comicviewer-bundle.XXXXXX.tar.gz")"
cat "$@" > "${temporary_archive}"
IFS=' ' read -r expected_checksum _ < "${archive_checksum}"
actual_checksum="$(sha256sum "${temporary_archive}")"
actual_checksum="${actual_checksum%% *}"
if [ "${actual_checksum}" != "${expected_checksum}" ]; then
printf '%s\n' "조립한 통합 번들의 체크섬이 일치하지 않습니다." >&2
exit 1
fi
extract_dir="$(mktemp -d "${TMPDIR:-/tmp}/comicviewer-install.XXXXXX")"
tar -xzf "${temporary_archive}" -C "${extract_dir}"
bundle_dir="${extract_dir}/${archive_name%.tar.gz}"
if [ ! -x "${bundle_dir}/install-or-update.sh" ]; then
printf '%s\n' "통합 번들에서 설치 스크립트를 찾을 수 없습니다." >&2
exit 1
fi
if [ -n "${action}" ]; then
"${bundle_dir}/install-or-update.sh" "${action}"
else
"${bundle_dir}/install-or-update.sh"
fi

View file

@ -1,23 +1,16 @@
use std::fs::File; use std::fs::File;
use std::io::{Cursor, Read}; use std::io::{Cursor, Read};
use std::path::Path; use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use image::codecs::gif::GifDecoder; use image::ImageReader;
use image::codecs::webp::WebPDecoder;
use image::metadata::LoopCount;
use image::{AnimationDecoder, ImageFormat, ImageReader};
use zip::ZipArchive; use zip::ZipArchive;
use crate::browser::natural_cmp_with_case; use crate::browser::natural_cmp;
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 30_000; const MAX_IMAGE_DIMENSION: u32 = 30_000;
const MAX_IMAGE_PIXELS: u64 = 100_000_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)] #[derive(Debug, Clone)]
pub struct ArchivePage { pub struct ArchivePage {
@ -28,16 +21,10 @@ pub struct ArchivePage {
pub struct DecodedPage { pub struct DecodedPage {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub frames: Vec<DecodedFrame>,
pub loop_count: Option<u32>,
}
pub struct DecodedFrame {
pub rgba: Vec<u8>, pub rgba: Vec<u8>,
pub delay: Duration,
} }
pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage>> { pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> {
let file = File::open(path) let file = File::open(path)
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
let mut archive = ZipArchive::new(file) let mut archive = ZipArchive::new(file)
@ -54,7 +41,7 @@ pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage
} }
} }
pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive)); pages.sort_by(|left, right| natural_cmp(&left.name, &right.name));
Ok(pages) Ok(pages)
} }
@ -83,123 +70,25 @@ pub fn decode_page(path: &Path, page: &ArchivePage) -> Result<DecodedPage> {
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name); bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
} }
decode_image_bytes(&encoded, &page.name) let reader = ImageReader::new(Cursor::new(&encoded))
}
pub fn decode_image_file(path: &Path, display_name: &str) -> Result<DecodedPage> {
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<DecodedPage> {
let reader = ImageReader::new(Cursor::new(encoded))
.with_guessed_format() .with_guessed_format()
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?; .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
let format = reader.format();
let (width, height) = reader let (width, height) = reader
.into_dimensions() .into_dimensions()
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?; .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
validate_dimensions(width, height, name)?; validate_dimensions(width, height, &page.name)?;
let animated = match format { let image = image::load_from_memory(&encoded)
Some(ImageFormat::Gif) => { .with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?;
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(); let rgba = image.into_rgba8();
Ok(DecodedPage { Ok(DecodedPage {
width, width,
height, height,
frames: vec![DecodedFrame { rgba: rgba.into_raw(),
rgba: rgba.into_raw(),
delay: Duration::ZERO,
}],
loop_count: Some(1),
}) })
} }
fn decoded_animation(
width: u32,
height: u32,
frames: impl Iterator<Item = image::ImageResult<image::Frame>>,
loop_count: Option<u32>,
name: &str,
) -> Result<DecodedPage> {
let expected_bytes = usize::try_from(width)?
.checked_mul(usize::try_from(height)?)
.and_then(|pixels| pixels.checked_mul(4))
.context("애니메이션 프레임 크기를 처리할 수 없습니다")?;
let mut decoded = Vec::new();
let mut total_bytes = 0_usize;
for (index, frame) in frames.enumerate() {
if index >= MAX_ANIMATION_FRAMES {
bail!(
"애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)"
);
}
let frame =
frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?;
total_bytes = total_bytes
.checked_add(expected_bytes)
.context("애니메이션 전체 크기를 처리할 수 없습니다")?;
if total_bytes > MAX_ANIMATION_BYTES {
bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)");
}
let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY);
let rgba = frame.into_buffer();
if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes {
bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}");
}
decoded.push(DecodedFrame {
rgba: rgba.into_raw(),
delay,
});
}
if decoded.is_empty() {
bail!("애니메이션에 표시할 프레임이 없습니다: {name}");
}
Ok(DecodedPage {
width,
height,
frames: decoded,
loop_count,
})
}
fn animation_loop_count(loop_count: LoopCount) -> Option<u32> {
match loop_count {
LoopCount::Infinite => None,
LoopCount::Finite(count) => Some(count.get()),
}
}
fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> { fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> {
let pixels = u64::from(width) * u64::from(height); let pixels = u64::from(width) * u64::from(height);
if width == 0 if width == 0
@ -272,7 +161,7 @@ mod tests {
archive.write_all(b"ignored").unwrap(); archive.write_all(b"ignored").unwrap();
archive.finish().unwrap(); archive.finish().unwrap();
let pages = list_pages(&path, true).unwrap(); let pages = list_pages(&path).unwrap();
assert_eq!( assert_eq!(
pages pages
.iter() .iter()
@ -282,51 +171,8 @@ mod tests {
); );
let decoded = decode_page(&path, &pages[0]).unwrap(); let decoded = decode_page(&path, &pages[0]).unwrap();
assert_eq!((decoded.width, decoded.height), (2, 3)); assert_eq!((decoded.width, decoded.height), (2, 3));
assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4); assert_eq!(decoded.rgba.len(), 2 * 3 * 4);
std::fs::remove_file(path).unwrap(); 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);
}
} }

View file

@ -6,22 +6,12 @@ use std::time::SystemTime;
pub enum EntryKind { pub enum EntryKind {
Directory, Directory,
ZipArchive, ZipArchive,
Image,
Video,
OtherFile,
}
impl EntryKind {
pub fn is_viewable(self) -> bool {
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
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>,
@ -29,12 +19,6 @@ 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,
@ -48,19 +32,11 @@ pub enum SortDirection {
Descending, Descending,
} }
pub fn sort_entries( pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
entries: &mut [BrowserEntry],
field: SortField,
direction: SortDirection,
case_insensitive: bool,
) {
entries.sort_by(|left, right| { entries.sort_by(|left, right| {
let kind_order = match ( let kind_order = match (left.kind, right.kind) {
left.kind == EntryKind::Directory, (EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less,
right.kind == EntryKind::Directory, (EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater,
) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => Ordering::Equal, _ => Ordering::Equal,
}; };
if kind_order != Ordering::Equal { if kind_order != Ordering::Equal {
@ -68,32 +44,11 @@ pub fn sort_entries(
} }
match field { match field {
SortField::Name => apply_direction( SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction),
natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive),
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(natural_cmp(&left.name, &right.name), direction)),
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) SortField::Created => compare_optional_time(left.created, right.created, direction)
.then_with(|| { .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
apply_direction(
natural_cmp_with_case(
left.display_name(),
right.display_name(),
case_insensitive,
),
direction,
)
}),
} }
}); });
} }
@ -118,28 +73,19 @@ fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
} }
} }
pub fn classify_file(path: &Path) -> EntryKind { pub fn is_zip_path(path: &Path) -> bool {
let extension = path path.extension()
.extension()
.and_then(|extension| extension.to_str()) .and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase); .is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
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_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering { pub fn natural_cmp(left: &str, right: &str) -> Ordering {
let mut left_chars = left.chars().peekable(); let mut left_chars = left.chars().peekable();
let mut right_chars = right.chars().peekable(); let mut right_chars = right.chars().peekable();
loop { loop {
match (left_chars.peek(), right_chars.peek()) { match (left_chars.peek(), right_chars.peek()) {
(None, None) => return left.cmp(right), (None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less, (None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater, (Some(_), None) => return Ordering::Greater,
(Some(left_char), Some(right_char)) (Some(left_char), Some(right_char))
@ -172,11 +118,10 @@ pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) ->
(Some(_), Some(_)) => { (Some(_), Some(_)) => {
let left_char = left_chars.next().unwrap(); let left_char = left_chars.next().unwrap();
let right_char = right_chars.next().unwrap(); let right_char = right_chars.next().unwrap();
let order = if case_insensitive { let order = left_char
left_char.to_lowercase().cmp(right_char.to_lowercase()) .to_lowercase()
} else { .cmp(right_char.to_lowercase())
left_char.cmp(&right_char) .then_with(|| left_char.cmp(&right_char));
};
if order != Ordering::Equal { if order != Ordering::Equal {
return order; return order;
} }
@ -203,67 +148,22 @@ mod tests {
#[test] #[test]
fn natural_order_places_two_before_ten() { fn natural_order_places_two_before_ten() {
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
names.sort_by(|left, right| natural_cmp_with_case(left, right, true)); names.sort_by(|left, right| natural_cmp(left, right));
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]); assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
} }
#[test] #[test]
fn natural_order_handles_numbers_larger_than_integers() { fn natural_order_handles_numbers_larger_than_integers() {
assert_eq!( assert_eq!(
natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true), natural_cmp("9.zip", "123456789012345678901234567890.zip"),
Ordering::Less 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] #[test]
fn zip_extension_is_case_insensitive() { fn zip_extension_is_case_insensitive() {
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); assert!(is_zip_path(Path::new("comic.ZIP")));
assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive); assert!(!is_zip_path(Path::new("comic.cbz")));
}
#[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] #[test]

View file

@ -10,12 +10,7 @@ use serde::{Deserialize, Serialize};
pub struct AppConfig { pub struct AppConfig {
pub cache_quota_gb: u64, pub cache_quota_gb: u64,
pub fast_image_loading: bool, pub fast_image_loading: bool,
pub case_insensitive_sort: bool,
pub viewer_mode: ViewerMode,
pub two_page_right_to_left: bool,
pub locations: Vec<LocationProfile>, pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>,
pub recent_files: Vec<RecentFileEntry>,
} }
impl Default for AppConfig { impl Default for AppConfig {
@ -23,12 +18,7 @@ impl Default for AppConfig {
Self { Self {
cache_quota_gb: 10, cache_quota_gb: 10,
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth,
two_page_right_to_left: true,
locations: Vec::new(), locations: Vec::new(),
favorites: Vec::new(),
recent_files: Vec::new(),
} }
} }
} }
@ -46,21 +36,6 @@ pub struct LocationProfile {
pub domain: String, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum LocationKind { pub enum LocationKind {
@ -70,39 +45,6 @@ pub enum LocationKind {
Sftp, 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 { impl AppConfig {
pub fn load_or_create(path: &Path) -> Result<Self> { pub fn load_or_create(path: &Path) -> Result<Self> {
if path.exists() { if path.exists() {
@ -180,7 +122,6 @@ mod tests {
#[test] #[test]
fn default_cache_quota_is_ten_gigabytes() { fn default_cache_quota_is_ten_gigabytes() {
assert_eq!(AppConfig::default().cache_quota_gb, 10); assert_eq!(AppConfig::default().cache_quota_gb, 10);
assert!(AppConfig::default().case_insensitive_sort);
} }
#[test] #[test]
@ -188,9 +129,6 @@ mod tests {
let config = AppConfig { let config = AppConfig {
cache_quota_gb: 0, cache_quota_gb: 0,
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true,
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,
@ -199,77 +137,16 @@ mod tests {
password: "plain-password".into(), password: "plain-password".into(),
domain: String::new(), 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 encoded = toml::to_string(&config).unwrap();
let decoded: AppConfig = toml::from_str(&encoded).unwrap(); let decoded: AppConfig = toml::from_str(&encoded).unwrap();
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.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]
fn fast_image_loading_is_enabled_for_existing_configs() { fn fast_image_loading_is_enabled_for_existing_configs() {
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
assert!(decoded.fast_image_loading); 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);
} }
} }

View file

@ -8,86 +8,36 @@ 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() let application = gtk::Application::builder().application_id(APP_ID).build();
.application_id(APP_ID)
.flags(application_flags())
.build();
let state = Rc::new(ApplicationState::default());
let activate_state = Rc::clone(&state); application.connect_activate(|application| {
application.connect_activate(move |application| { if let Err(error) = activate(application) {
match activate_state.ensure_main_window(application) { eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}");
Ok(window) => window.present(), application.quit();
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();
} }
#[derive(Default)] fn activate(application: &gtk::Application) -> Result<()> {
struct ApplicationState { let app_paths = paths::AppPaths::discover()?;
main_window: RefCell<Option<Rc<window::ComicWindow>>>, 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)?;
impl ApplicationState { let window = window::ComicWindow::new(
fn ensure_main_window( application,
&self, config,
application: &gtk::Application, app_paths.config_file,
) -> Result<Rc<window::ComicWindow>> { database,
if let Some(window) = self.main_window.borrow().as_ref() { zip_cache,
return Ok(Rc::clone(window)); )?;
} window.present();
let app_paths = paths::AppPaths::discover()?; Ok(())
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 = Rc::new(window::ComicWindow::new(
application,
config,
app_paths.config_file,
database,
zip_cache,
)?);
self.main_window.replace(Some(Rc::clone(&window)));
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,4 +1,3 @@
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};
@ -8,47 +7,10 @@ use gtk::gio::prelude::*;
use gtk::glib; use gtk::glib;
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use crate::browser::{BrowserEntry, EntryKind, classify_file}; use crate::browser::{BrowserEntry, EntryKind, is_zip_path};
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)?;
@ -138,7 +100,7 @@ where
} }
}; };
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut enumeration = EnumerationProgress::default(); let mut scanned = 0;
loop { loop {
let batch = enumerator let batch = enumerator
@ -147,25 +109,24 @@ where
if batch.is_empty() { if batch.is_empty() {
break; break;
} }
enumeration.begin_batch(); scanned += batch.len();
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 if is_zip_path(Path::new(info.display_name().as_str())) => {
EntryKind::ZipArchive
}
_ => 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")
@ -181,151 +142,10 @@ 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>,
@ -359,26 +179,6 @@ pub fn mount_operation(
operation operation
} }
pub async fn ensure_mounted(
file: &gio::File,
profile: &LocationProfile,
operation: &gtk::MountOperation,
) -> Result<()> {
if profile.kind == LocationKind::Local || file.is_native() {
return Ok(());
}
match file
.mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation))
.await
{
Ok(()) => Ok(()),
Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => Ok(()),
Err(error) => {
Err(error).with_context(|| format!("원격 위치를 마운트할 수 없습니다: {}", file.uri()))
}
}
}
async fn enumerate_with_mount_retry( async fn enumerate_with_mount_retry(
directory: &gio::File, directory: &gio::File,
operation: &gtk::MountOperation, operation: &gtk::MountOperation,
@ -421,16 +221,8 @@ 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]
@ -480,63 +272,4 @@ 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

@ -1,4 +1,4 @@
use std::cell::{Cell, RefCell}; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::{self, File}; use std::fs::{self, File};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -17,25 +17,16 @@ use crate::browser::BrowserEntry;
pub struct ArchiveSource { pub struct ArchiveSource {
pub local_path: PathBuf, pub local_path: PathBuf,
pub source_uri: String, pub source_uri: String,
pub display_name: String,
_temporary: Option<Rc<tempfile::TempPath>>, _temporary: Option<Rc<tempfile::TempPath>>,
_pin: Option<Rc<CachePin>>, _pin: Option<Rc<CachePin>>,
} }
pub struct ZipCache { pub struct ZipCache {
directory: PathBuf, directory: PathBuf,
quota_bytes: Cell<u64>, quota_bytes: u64,
pinned: RefCell<HashMap<PathBuf, usize>>, pinned: RefCell<HashMap<PathBuf, usize>>,
} }
#[derive(Debug, Default, PartialEq, Eq)]
pub struct CacheCleanupReport {
pub removed_files: usize,
pub removed_bytes: u64,
pub retained_open_files: usize,
pub remaining_bytes: u64,
}
struct CachePin { struct CachePin {
path: PathBuf, path: PathBuf,
cache: Weak<ZipCache>, cache: Weak<ZipCache>,
@ -45,18 +36,12 @@ impl Drop for CachePin {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() { if let Some(cache) = self.cache.upgrade() {
let mut pinned = cache.pinned.borrow_mut(); let mut pinned = cache.pinned.borrow_mut();
let mut released = false;
if let Some(count) = pinned.get_mut(&self.path) { if let Some(count) = pinned.get_mut(&self.path) {
*count -= 1; *count -= 1;
if *count == 0 { if *count == 0 {
pinned.remove(&self.path); pinned.remove(&self.path);
released = true;
} }
} }
drop(pinned);
if released {
let _ = cache.enforce_quota(None);
}
} }
} }
} }
@ -66,24 +51,18 @@ impl ZipCache {
fs::create_dir_all(&directory)?; fs::create_dir_all(&directory)?;
for entry in fs::read_dir(&directory)? { for entry in fs::read_dir(&directory)? {
let path = entry?.path(); let path = entry?.path();
let incomplete_download = path if path
.extension() .extension()
.is_some_and(|extension| extension == "part"); .is_some_and(|extension| extension == "part")
let stale_temporary = path {
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"));
if incomplete_download || stale_temporary {
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
} }
let cache = Rc::new(Self { Ok(Rc::new(Self {
directory, directory,
quota_bytes: Cell::new(quota_gb.saturating_mul(1024 * 1024 * 1024)), quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
pinned: RefCell::new(HashMap::new()), pinned: RefCell::new(HashMap::new()),
}); }))
cache.enforce_quota(None)?;
Ok(cache)
} }
pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F) pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
@ -96,63 +75,31 @@ impl ZipCache {
callback(Ok(ArchiveSource { callback(Ok(ArchiveSource {
local_path: path, local_path: path,
source_uri: entry.uri, source_uri: entry.uri,
display_name: entry.name,
_temporary: None, _temporary: None,
_pin: None, _pin: None,
})); }));
return; return;
} }
let quota_bytes = self.quota_bytes.get(); if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) { self.download_temporary(source, entry.uri, entry.size, progress, callback);
self.download_temporary(source, entry, true, progress, callback);
} else { } else {
self.download_persistent(source, entry, progress, callback); self.download_persistent(source, entry, progress, callback);
} }
} }
pub fn prepare_media<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
where
F: FnOnce(Result<ArchiveSource>) + 'static,
P: FnMut(i64, i64) + 'static,
{
let source = gio::File::for_uri(&entry.uri);
if let Some(path) = native_path(&source) {
callback(Ok(ArchiveSource {
local_path: path,
source_uri: entry.uri,
display_name: entry.name,
_temporary: None,
_pin: None,
}));
return;
}
self.download_temporary(source, entry, false, progress, callback);
}
fn download_temporary<F, P>( fn download_temporary<F, P>(
self: &Rc<Self>, self: &Rc<Self>,
source: gio::File, source: gio::File,
entry: BrowserEntry, source_uri: String,
validate_zip: bool, expected_size: Option<u64>,
progress: P, progress: P,
callback: F, callback: F,
) where ) where
F: FnOnce(Result<ArchiveSource>) + 'static, F: FnOnce(Result<ArchiveSource>) + 'static,
P: FnMut(i64, i64) + 'static, P: FnMut(i64, i64) + 'static,
{ {
let suffix = Path::new(&entry.name) let temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() {
.extension()
.and_then(|extension| extension.to_str())
.map_or_else(
|| ".part".to_owned(),
|extension| format!(".part.{extension}"),
);
let temporary = match tempfile::Builder::new()
.prefix("temporary-")
.suffix(&suffix)
.tempfile_in(&self.directory)
{
Ok(file) => Rc::new(file.into_temp_path()), Ok(file) => Rc::new(file.into_temp_path()),
Err(error) => { Err(error) => {
callback(Err(error.into())); callback(Err(error.into()));
@ -161,7 +108,6 @@ impl ZipCache {
}; };
let destination = gio::File::for_path(temporary.as_ref()); let destination = gio::File::for_path(temporary.as_ref());
let retained = Rc::clone(&temporary); let retained = Rc::clone(&temporary);
let expected_size = entry.size;
source.copy_async( source.copy_async(
&destination, &destination,
gio::FileCopyFlags::OVERWRITE, gio::FileCopyFlags::OVERWRITE,
@ -170,14 +116,10 @@ impl ZipCache {
Some(Box::new(progress)), Some(Box::new(progress)),
move |result| { move |result| {
callback(result.map_err(Into::into).and_then(|()| { callback(result.map_err(Into::into).and_then(|()| {
validate_size(retained.as_ref(), expected_size)?; validate_download(retained.as_ref(), expected_size)?;
if validate_zip {
validate_zip_file(retained.as_ref())?;
}
Ok(ArchiveSource { Ok(ArchiveSource {
local_path: retained.to_path_buf(), local_path: retained.to_path_buf(),
source_uri: entry.uri, source_uri,
display_name: entry.name,
_temporary: Some(retained), _temporary: Some(retained),
_pin: None, _pin: None,
}) })
@ -201,19 +143,13 @@ impl ZipCache {
if final_path.is_file() { if final_path.is_file() {
if validate_download(&final_path, entry.size).is_ok() { if validate_download(&final_path, entry.size).is_ok() {
let _ = filetime::set_file_mtime(&final_path, FileTime::now()); let _ = filetime::set_file_mtime(&final_path, FileTime::now());
callback(Ok(self.lease(final_path, entry.uri, entry.name))); callback(Ok(self.lease(final_path, entry.uri)));
return; return;
} }
let _ = fs::remove_file(&final_path); let _ = fs::remove_file(&final_path);
} }
let part_path = self.directory.join(format!("{key}.part")); let part_path = self.directory.join(format!("{key}.part"));
if let Some(required_bytes) = entry.size
&& let Err(error) = self.make_room_for(required_bytes)
{
callback(Err(error));
return;
}
let destination = gio::File::for_path(&part_path); let destination = gio::File::for_path(&part_path);
let cache = Rc::clone(self); let cache = Rc::clone(self);
source.copy_async( source.copy_async(
@ -231,8 +167,8 @@ impl ZipCache {
final_path.display() final_path.display()
) )
})?; })?;
cache.enforce_quota(Some(&final_path))?; cache.enforce_quota(&final_path)?;
Ok(cache.lease(final_path, entry.uri, entry.name)) Ok(cache.lease(final_path, entry.uri))
}); });
if result.is_err() { if result.is_err() {
let _ = fs::remove_file(&part_path); let _ = fs::remove_file(&part_path);
@ -242,35 +178,11 @@ impl ZipCache {
); );
} }
pub fn clear_persistent(&self) -> Result<CacheCleanupReport> { fn enforce_quota(&self, protected: &Path) -> Result<()> {
self.cleanup_to_limit(0, None)
}
fn make_room_for(&self, required_bytes: u64) -> Result<()> {
let limit = self.quota_bytes.get().saturating_sub(required_bytes);
let report = self.cleanup_to_limit(limit, None)?;
if report.remaining_bytes > limit {
bail!("열려 있는 ZIP 때문에 다운로드에 필요한 캐시 공간을 확보할 수 없습니다");
}
Ok(())
}
fn enforce_quota(&self, protected: Option<&Path>) -> Result<CacheCleanupReport> {
self.cleanup_to_limit(self.quota_bytes.get(), protected)
}
fn cleanup_to_limit(&self, limit: u64, protected: Option<&Path>) -> Result<CacheCleanupReport> {
let mut files = Vec::new(); let mut files = Vec::new();
let mut total = 0_u64; let mut total = 0_u64;
for entry in fs::read_dir(&self.directory)? { for entry in fs::read_dir(&self.directory)? {
let path = entry?.path(); let path = entry?.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"))
{
continue;
}
if path.extension().is_none_or(|extension| extension != "zip") { if path.extension().is_none_or(|extension| extension != "zip") {
continue; continue;
} }
@ -279,39 +191,25 @@ impl ZipCache {
files.push((metadata.modified().ok(), metadata.len(), path)); files.push((metadata.modified().ok(), metadata.len(), path));
} }
files.sort_by_key(|(modified, _, path)| (*modified, path.clone())); files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
let mut report = CacheCleanupReport::default();
for (_, size, path) in files { for (_, size, path) in files {
if total <= limit { if total <= self.quota_bytes {
break; break;
} }
if protected == Some(path.as_path()) { if path != protected
continue; && !self.pinned.borrow().contains_key(&path)
&& fs::remove_file(&path).is_ok()
{
total = total.saturating_sub(size);
} }
if self.pinned.borrow().contains_key(&path) {
report.retained_open_files += 1;
continue;
}
fs::remove_file(&path)
.with_context(|| format!("캐시 파일을 삭제할 수 없습니다: {}", path.display()))?;
total = total.saturating_sub(size);
report.removed_files += 1;
report.removed_bytes = report.removed_bytes.saturating_add(size);
} }
report.remaining_bytes = total; Ok(())
Ok(report)
} }
fn lease( fn lease(self: &Rc<Self>, path: PathBuf, source_uri: String) -> ArchiveSource {
self: &Rc<Self>,
path: PathBuf,
source_uri: String,
display_name: String,
) -> ArchiveSource {
*self.pinned.borrow_mut().entry(path.clone()).or_default() += 1; *self.pinned.borrow_mut().entry(path.clone()).or_default() += 1;
ArchiveSource { ArchiveSource {
local_path: path.clone(), local_path: path.clone(),
source_uri, source_uri,
display_name,
_temporary: None, _temporary: None,
_pin: Some(Rc::new(CachePin { _pin: Some(Rc::new(CachePin {
path, path,
@ -326,11 +224,6 @@ fn native_path(file: &gio::File) -> Option<PathBuf> {
} }
fn validate_download(path: &Path, expected_size: Option<u64>) -> Result<()> { fn validate_download(path: &Path, expected_size: Option<u64>) -> Result<()> {
validate_size(path, expected_size)?;
validate_zip_file(path)
}
fn validate_size(path: &Path, expected_size: Option<u64>) -> Result<()> {
let actual_size = fs::metadata(path)?.len(); let actual_size = fs::metadata(path)?.len();
if let Some(expected_size) = expected_size if let Some(expected_size) = expected_size
&& actual_size != expected_size && actual_size != expected_size
@ -339,10 +232,6 @@ fn validate_size(path: &Path, expected_size: Option<u64>) -> Result<()> {
"다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트" "다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트"
); );
} }
Ok(())
}
fn validate_zip_file(path: &Path) -> Result<()> {
let file = File::open(path)?; let file = File::open(path)?;
zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?; zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?;
Ok(()) Ok(())
@ -375,7 +264,6 @@ 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,
@ -415,113 +303,18 @@ mod tests {
fs::write(&second, [0_u8; 8]).unwrap(); fs::write(&second, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache { let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(), directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(8), quota_bytes: 8,
pinned: RefCell::new(HashMap::new()), pinned: RefCell::new(HashMap::new()),
}); });
let lease = cache.lease( let lease = cache.lease(first.clone(), "file:///first.zip".into());
first.clone(), cache.enforce_quota(&second).unwrap();
"file:///first.zip".into(),
"first.zip".into(),
);
cache.enforce_quota(Some(&second)).unwrap();
assert!(first.exists()); assert!(first.exists());
assert!(second.exists()); assert!(second.exists());
drop(lease); drop(lease);
cache.enforce_quota(Some(&second)).unwrap(); cache.enforce_quota(&second).unwrap();
assert!(!first.exists()); assert!(!first.exists());
assert!(second.exists()); assert!(second.exists());
} }
#[test]
fn lru_removes_oldest_cache_file_first() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(8),
pinned: RefCell::new(HashMap::new()),
};
let report = cache.enforce_quota(None).unwrap();
assert_eq!(report.removed_files, 1);
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn download_reserves_space_before_writing() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
};
cache.make_room_for(8).unwrap();
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn zero_quota_clears_existing_cache_on_startup() {
let directory = tempfile::tempdir().unwrap();
let cached = directory.path().join("cached.zip");
fs::write(&cached, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 0).unwrap();
assert!(!cached.exists());
}
#[test]
fn startup_removes_stale_temporary_downloads() {
let directory = tempfile::tempdir().unwrap();
let temporary = directory.path().join("temporary-abcd.part.mp4");
fs::write(&temporary, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 10).unwrap();
assert!(!temporary.exists());
}
#[test]
fn manual_clear_keeps_open_archives() {
let directory = tempfile::tempdir().unwrap();
let open = directory.path().join("open.zip");
let unused = directory.path().join("unused.zip");
let temporary = directory.path().join("temporary-download.part.zip");
fs::write(&open, [0_u8; 8]).unwrap();
fs::write(&unused, [0_u8; 8]).unwrap();
fs::write(&temporary, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
});
let lease = cache.lease(open.clone(), "file:///open.zip".into(), "open.zip".into());
let report = cache.clear_persistent().unwrap();
assert_eq!(report.removed_files, 1);
assert_eq!(report.retained_open_files, 1);
assert!(open.exists());
assert!(!unused.exists());
assert!(temporary.exists());
drop(lease);
}
} }