Add standalone media and remote downloads
This commit is contained in:
parent
8a213462e0
commit
5f9b5d9796
14 changed files with 683 additions and 86 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.1.4"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.1.4"
|
version = "0.2.0"
|
||||||
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"
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# Comicviewer
|
# Comicviewer
|
||||||
|
|
||||||
Comicviewer is a GTK4 desktop application for browsing and reading comic image
|
Comicviewer is a GTK4 desktop application for browsing ZIP archives, images,
|
||||||
archives stored locally or on SMB, FTP, and SFTP servers.
|
and videos stored locally or on SMB, FTP, and SFTP servers.
|
||||||
|
|
||||||
한국어 문서:
|
한국어 문서:
|
||||||
|
|
||||||
|
|
@ -11,15 +11,16 @@ archives 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, reading progress, and
|
two-page viewing, continuous vertical scrolling, standalone image and video
|
||||||
XDG-compliant configuration.
|
playback, reading progress, and 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
|
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:
|
Run the application with:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
</provides>
|
</provides>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.2.0" date="2026-07-21" />
|
||||||
<release version="0.1.4" date="2026-07-21" />
|
<release version="0.1.4" date="2026-07-21" />
|
||||||
<release version="0.1.3" 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" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Comicviewer 설치 및 실행 안내
|
# Comicviewer 설치 및 실행 안내
|
||||||
|
|
||||||
Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰어입니다.
|
Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디어 뷰어입니다.
|
||||||
로컬 파일뿐 아니라 SMB, FTP, SFTP 서버의 폴더와 ZIP 파일도 열 수 있습니다.
|
로컬 및 SMB, FTP, SFTP 위치의 ZIP, 이미지, 동영상 파일을 열 수 있습니다.
|
||||||
|
|
||||||
## 1. 필수 패키지
|
## 1. 필수 패키지
|
||||||
|
|
||||||
|
|
@ -9,34 +9,36 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰
|
||||||
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
|
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb
|
sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \
|
||||||
|
gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
|
||||||
```
|
```
|
||||||
|
|
||||||
- `fuse2`: AppImage 마운트 및 실행
|
- `fuse2`: AppImage 마운트 및 실행
|
||||||
- `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩
|
- `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩
|
||||||
- `gvfs`: FTP와 SFTP 접근
|
- `gvfs`: FTP와 SFTP 접근
|
||||||
- `gvfs-smb`: SMB 공유 접근
|
- `gvfs-smb`: SMB 공유 접근
|
||||||
|
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
|
||||||
|
|
||||||
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
|
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
|
||||||
|
|
||||||
다음 두 파일을 같은 폴더에 다운로드합니다.
|
다음 두 파일을 같은 폴더에 다운로드합니다.
|
||||||
|
|
||||||
- [Comicviewer-0.1.4-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz)
|
- [Comicviewer-latest-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz)
|
||||||
- [Comicviewer-0.1.4-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256)
|
- [Comicviewer-latest-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256)
|
||||||
|
|
||||||
터미널에서 직접 다운로드할 수도 있습니다.
|
터미널에서 직접 다운로드할 수도 있습니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz
|
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-0.1.4-x86_64.tar.gz.sha256
|
curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다.
|
체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
|
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
tar -xzf Comicviewer-0.1.4-x86_64.tar.gz
|
tar -xzf Comicviewer-latest-x86_64.tar.gz
|
||||||
cd Comicviewer-0.1.4-x86_64
|
cd Comicviewer-latest-x86_64
|
||||||
./install-or-update.sh
|
./install-or-update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ Release 첨부 파일로 제공하는 방식을 권장합니다. 버전별 바
|
||||||
기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이
|
기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이
|
||||||
절차를 사용합니다.
|
절차를 사용합니다.
|
||||||
|
|
||||||
태그, 프로젝트 버전, AppImage, 통합 번들의 버전은 반드시 같아야 합니다.
|
태그, 프로젝트 버전, AppImage의 내부 버전은 반드시 같아야 합니다. 사용자가
|
||||||
예를 들어 `v0.1.4` Release에는 이름에 `0.1.4`가 포함된 파일만 첨부합니다.
|
받는 통합 번들은 버전과 관계없이 `Comicviewer-latest-x86_64.tar.gz`라는 고정
|
||||||
Git 태그만 푸시하면 첨부 파일은 자동으로 등록되지 않으므로, 마지막 단계에서
|
이름을 사용합니다.
|
||||||
Release를 만들고 파일 네 개를 직접 첨부해야 합니다.
|
|
||||||
|
|
||||||
## 1. 버전 갱신
|
## 1. 버전 갱신
|
||||||
|
|
||||||
|
|
@ -56,9 +55,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
|
||||||
|
|
||||||
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
||||||
|
|
||||||
- `dist/Comicviewer-0.1.4-x86_64.AppImage.sha256`
|
- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256`
|
||||||
- `dist/Comicviewer-0.1.4-x86_64.tar.gz`
|
- `dist/Comicviewer-latest-x86_64.tar.gz`
|
||||||
- `dist/Comicviewer-0.1.4-x86_64.tar.gz.sha256`
|
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
|
||||||
|
|
||||||
통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘,
|
통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘,
|
||||||
한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를
|
한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를
|
||||||
|
|
@ -67,9 +66,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
|
||||||
생성된 번들의 압축 목록과 체크섬을 확인합니다.
|
생성된 번들의 압축 목록과 체크섬을 확인합니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
tar -tzf dist/Comicviewer-0.1.4-x86_64.tar.gz
|
tar -tzf dist/Comicviewer-latest-x86_64.tar.gz
|
||||||
cd dist
|
cd dist
|
||||||
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
|
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. 소스 커밋과 태그
|
## 5. 소스 커밋과 태그
|
||||||
|
|
@ -79,10 +78,10 @@ sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git add Cargo.toml Cargo.lock data packaging README.md docs
|
git add Cargo.toml Cargo.lock data packaging README.md docs
|
||||||
git commit -m "Prepare v0.1.4 release"
|
git commit -m "Prepare v0.2.0 release"
|
||||||
git tag -a v0.1.4 -m "Comicviewer v0.1.4"
|
git tag -a v0.2.0 -m "Comicviewer v0.2.0"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v0.1.4
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||||
|
|
@ -93,28 +92,29 @@ git push origin v0.1.4
|
||||||
Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을
|
Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을
|
||||||
`/www/Downloads/Comicviewer/`에 업로드합니다.
|
`/www/Downloads/Comicviewer/`에 업로드합니다.
|
||||||
|
|
||||||
- `Comicviewer-0.1.4-x86_64.tar.gz`
|
- `Comicviewer-latest-x86_64.tar.gz`
|
||||||
- `Comicviewer-0.1.4-x86_64.tar.gz.sha256`
|
- `Comicviewer-latest-x86_64.tar.gz.sha256`
|
||||||
|
|
||||||
업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과
|
업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과
|
||||||
실제 설치를 확인합니다.
|
실제 설치를 확인합니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz
|
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz
|
||||||
https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256
|
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
|
필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb, gstreamer,
|
||||||
|
gst-plugins-base, gst-plugins-good, gst-plugins-bad, gst-libav
|
||||||
|
|
||||||
설치 또는 업데이트:
|
설치 또는 업데이트:
|
||||||
위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다.
|
위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다.
|
||||||
sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256
|
sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
tar -xzf Comicviewer-0.1.4-x86_64.tar.gz
|
tar -xzf Comicviewer-latest-x86_64.tar.gz
|
||||||
cd Comicviewer-0.1.4-x86_64
|
cd Comicviewer-latest-x86_64
|
||||||
./install-or-update.sh
|
./install-or-update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ 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##*@}"
|
||||||
bundle_name="Comicviewer-${version}-x86_64"
|
artifact_name="Comicviewer-${version}-x86_64"
|
||||||
appimage_name="${bundle_name}.AppImage"
|
bundle_name="Comicviewer-latest-x86_64"
|
||||||
|
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"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ install_dependencies() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
set --
|
set --
|
||||||
for package in fuse2 gtk4 dav1d gvfs gvfs-smb; do
|
for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \
|
||||||
|
gst-plugins-good gst-plugins-bad gst-libav; do
|
||||||
if ! pacman -Q "${package}" >/dev/null 2>&1; then
|
if ! pacman -Q "${package}" >/dev/null 2>&1; then
|
||||||
set -- "$@" "${package}"
|
set -- "$@" "${package}"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -70,16 +70,33 @@ pub fn decode_page(path: &Path, page: &ArchivePage) -> Result<DecodedPage> {
|
||||||
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
|
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<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!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
|
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
|
||||||
let (width, height) = reader
|
let (width, height) = reader
|
||||||
.into_dimensions()
|
.into_dimensions()
|
||||||
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
|
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
|
||||||
validate_dimensions(width, height, &page.name)?;
|
validate_dimensions(width, height, name)?;
|
||||||
|
|
||||||
let image = image::load_from_memory(&encoded)
|
let image = image::load_from_memory(encoded)
|
||||||
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?;
|
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
|
||||||
let rgba = image.into_rgba8();
|
let rgba = image.into_rgba8();
|
||||||
|
|
||||||
Ok(DecodedPage {
|
Ok(DecodedPage {
|
||||||
|
|
@ -175,4 +192,16 @@ mod tests {
|
||||||
|
|
||||||
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.rgba.len(), 4 * 5 * 4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ 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)]
|
||||||
|
|
@ -34,9 +43,12 @@ pub enum SortDirection {
|
||||||
|
|
||||||
pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
|
pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
|
||||||
entries.sort_by(|left, right| {
|
entries.sort_by(|left, right| {
|
||||||
let kind_order = match (left.kind, right.kind) {
|
let kind_order = match (
|
||||||
(EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less,
|
left.kind == EntryKind::Directory,
|
||||||
(EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater,
|
right.kind == EntryKind::Directory,
|
||||||
|
) {
|
||||||
|
(true, false) => Ordering::Less,
|
||||||
|
(false, true) => Ordering::Greater,
|
||||||
_ => Ordering::Equal,
|
_ => Ordering::Equal,
|
||||||
};
|
};
|
||||||
if kind_order != Ordering::Equal {
|
if kind_order != Ordering::Equal {
|
||||||
|
|
@ -73,10 +85,19 @@ fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_zip_path(path: &Path) -> bool {
|
pub fn classify_file(path: &Path) -> EntryKind {
|
||||||
path.extension()
|
let extension = path
|
||||||
|
.extension()
|
||||||
.and_then(|extension| extension.to_str())
|
.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(left: &str, right: &str) -> Ordering {
|
||||||
|
|
@ -162,8 +183,17 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zip_extension_is_case_insensitive() {
|
fn zip_extension_is_case_insensitive() {
|
||||||
assert!(is_zip_path(Path::new("comic.ZIP")));
|
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
|
||||||
assert!(!is_zip_path(Path::new("comic.cbz")));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ 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, is_zip_path};
|
use crate::browser::{BrowserEntry, EntryKind, classify_file};
|
||||||
use crate::config::{LocationKind, LocationProfile};
|
use crate::config::{LocationKind, LocationProfile};
|
||||||
|
|
||||||
const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
|
const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
|
||||||
|
|
@ -115,9 +115,7 @@ where
|
||||||
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 if is_zip_path(Path::new(info.display_name().as_str())) => {
|
gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())),
|
||||||
EntryKind::ZipArchive
|
|
||||||
}
|
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
let child = enumerator.child(&info);
|
let child = enumerator.child(&info);
|
||||||
|
|
|
||||||
278
src/viewer.rs
278
src/viewer.rs
|
|
@ -9,7 +9,8 @@ use gtk::gdk;
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|
||||||
use crate::archive::{ArchivePage, DecodedPage, decode_page, list_pages};
|
use crate::archive::{ArchivePage, DecodedPage, decode_image_file, decode_page, list_pages};
|
||||||
|
use crate::browser::EntryKind;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::zip_cache::ArchiveSource;
|
use crate::zip_cache::ArchiveSource;
|
||||||
|
|
||||||
|
|
@ -20,18 +21,17 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64;
|
||||||
|
|
||||||
pub struct ViewerWindow;
|
pub struct ViewerWindow;
|
||||||
|
|
||||||
|
pub type SiblingNavigator = Rc<dyn Fn(bool) -> bool>;
|
||||||
|
|
||||||
impl ViewerWindow {
|
impl ViewerWindow {
|
||||||
pub fn open(
|
pub fn open(
|
||||||
application: >k::Application,
|
application: >k::Application,
|
||||||
source: ArchiveSource,
|
source: ArchiveSource,
|
||||||
database: Rc<Database>,
|
database: Rc<Database>,
|
||||||
fast_image_loading: bool,
|
fast_image_loading: bool,
|
||||||
|
sibling_navigator: Option<SiblingNavigator>,
|
||||||
) {
|
) {
|
||||||
let title = source
|
let title = source.display_name.clone();
|
||||||
.local_path
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_else(|| "Comicviewer".into());
|
|
||||||
let window = gtk::ApplicationWindow::builder()
|
let window = gtk::ApplicationWindow::builder()
|
||||||
.application(application)
|
.application(application)
|
||||||
.title(&title)
|
.title(&title)
|
||||||
|
|
@ -119,8 +119,14 @@ impl ViewerWindow {
|
||||||
.margin_start(8)
|
.margin_start(8)
|
||||||
.margin_end(8)
|
.margin_end(8)
|
||||||
.build();
|
.build();
|
||||||
|
let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0);
|
||||||
|
page_seek.set_draw_value(false);
|
||||||
|
page_seek.set_sensitive(false);
|
||||||
|
page_seek.set_margin_start(8);
|
||||||
|
page_seek.set_margin_end(8);
|
||||||
root.append(&toolbar);
|
root.append(&toolbar);
|
||||||
root.append(&overlay);
|
root.append(&overlay);
|
||||||
|
root.append(&page_seek);
|
||||||
root.append(&status_label);
|
root.append(&status_label);
|
||||||
window.set_child(Some(&root));
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
|
@ -136,6 +142,7 @@ impl ViewerWindow {
|
||||||
continuous_scrolled,
|
continuous_scrolled,
|
||||||
spinner,
|
spinner,
|
||||||
page_label,
|
page_label,
|
||||||
|
page_seek,
|
||||||
status_label,
|
status_label,
|
||||||
previous_button,
|
previous_button,
|
||||||
next_button,
|
next_button,
|
||||||
|
|
@ -144,6 +151,7 @@ impl ViewerWindow {
|
||||||
mode_dropdown,
|
mode_dropdown,
|
||||||
direction_dropdown,
|
direction_dropdown,
|
||||||
fast_image_loading,
|
fast_image_loading,
|
||||||
|
sibling_navigator,
|
||||||
state: RefCell::new(ViewerState {
|
state: RefCell::new(ViewerState {
|
||||||
pages: Vec::new(),
|
pages: Vec::new(),
|
||||||
anchor: 0,
|
anchor: 0,
|
||||||
|
|
@ -210,6 +218,7 @@ struct ViewerController {
|
||||||
continuous_scrolled: gtk::ScrolledWindow,
|
continuous_scrolled: gtk::ScrolledWindow,
|
||||||
spinner: gtk::Spinner,
|
spinner: gtk::Spinner,
|
||||||
page_label: gtk::Label,
|
page_label: gtk::Label,
|
||||||
|
page_seek: gtk::Scale,
|
||||||
status_label: gtk::Label,
|
status_label: gtk::Label,
|
||||||
previous_button: gtk::Button,
|
previous_button: gtk::Button,
|
||||||
next_button: gtk::Button,
|
next_button: gtk::Button,
|
||||||
|
|
@ -218,6 +227,7 @@ struct ViewerController {
|
||||||
mode_dropdown: gtk::DropDown,
|
mode_dropdown: gtk::DropDown,
|
||||||
direction_dropdown: gtk::DropDown,
|
direction_dropdown: gtk::DropDown,
|
||||||
fast_image_loading: bool,
|
fast_image_loading: bool,
|
||||||
|
sibling_navigator: Option<SiblingNavigator>,
|
||||||
state: RefCell<ViewerState>,
|
state: RefCell<ViewerState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +251,14 @@ impl ViewerController {
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
self.direction_dropdown
|
self.direction_dropdown
|
||||||
.connect_selected_notify(move |_| controller.render());
|
.connect_selected_notify(move |_| controller.render());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.page_seek.connect_value_changed(move |scale| {
|
||||||
|
if let Some(index) =
|
||||||
|
page_seek_index(scale.value(), controller.state.borrow().pages.len())
|
||||||
|
{
|
||||||
|
controller.seek_page(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let adjustment = self.continuous_scrolled.vadjustment();
|
let adjustment = self.continuous_scrolled.vadjustment();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
|
|
@ -278,6 +296,14 @@ impl ViewerController {
|
||||||
controller.navigate(true);
|
controller.navigate(true);
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
|
_ if key.to_unicode() == Some('[') => {
|
||||||
|
controller.open_sibling(false);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ if key.to_unicode() == Some(']') => {
|
||||||
|
controller.open_sibling(true);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
_ if key
|
_ if key
|
||||||
.to_unicode()
|
.to_unicode()
|
||||||
.is_some_and(|character| character.eq_ignore_ascii_case(&'f')) =>
|
.is_some_and(|character| character.eq_ignore_ascii_case(&'f')) =>
|
||||||
|
|
@ -294,6 +320,14 @@ impl ViewerController {
|
||||||
self.window.add_controller(key_controller);
|
self.window.add_controller(key_controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_sibling(&self, forward: bool) {
|
||||||
|
if let Some(navigate) = &self.sibling_navigator
|
||||||
|
&& navigate(forward)
|
||||||
|
{
|
||||||
|
self.window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle_fullscreen(&self) {
|
fn toggle_fullscreen(&self) {
|
||||||
if self.window.is_fullscreen() {
|
if self.window.is_fullscreen() {
|
||||||
self.window.unfullscreen();
|
self.window.unfullscreen();
|
||||||
|
|
@ -332,6 +366,7 @@ impl ViewerController {
|
||||||
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
|
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
|
||||||
}
|
}
|
||||||
Ok(pages) => {
|
Ok(pages) => {
|
||||||
|
let page_count = pages.len();
|
||||||
let saved = self
|
let saved = self
|
||||||
.database
|
.database
|
||||||
.load_progress(&self.source.source_uri)
|
.load_progress(&self.source.source_uri)
|
||||||
|
|
@ -343,6 +378,8 @@ impl ViewerController {
|
||||||
state.pages = pages;
|
state.pages = pages;
|
||||||
state.anchor = saved;
|
state.anchor = saved;
|
||||||
drop(state);
|
drop(state);
|
||||||
|
self.page_seek.set_range(0.0, (page_count - 1) as f64);
|
||||||
|
self.page_seek.set_sensitive(page_count > 1);
|
||||||
self.build_continuous_rows();
|
self.build_continuous_rows();
|
||||||
self.render();
|
self.render();
|
||||||
}
|
}
|
||||||
|
|
@ -461,6 +498,23 @@ impl ViewerController {
|
||||||
self.render();
|
self.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seek_page(self: &Rc<Self>, index: usize) {
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.pages.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = index.min(state.pages.len() - 1);
|
||||||
|
if index == state.anchor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.anchor = index;
|
||||||
|
drop(state);
|
||||||
|
self.render();
|
||||||
|
if self.view_mode() == ViewMode::Continuous {
|
||||||
|
self.schedule_scroll_to_anchor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render(self: &Rc<Self>) {
|
fn render(self: &Rc<Self>) {
|
||||||
let visible = self.visible_indices();
|
let visible = self.visible_indices();
|
||||||
if visible.is_empty() {
|
if visible.is_empty() {
|
||||||
|
|
@ -838,26 +892,209 @@ impl ViewerController {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_navigation(&self) {
|
fn update_navigation(&self) {
|
||||||
let state = self.state.borrow();
|
let (count, anchor) = {
|
||||||
let count = state.pages.len();
|
let state = self.state.borrow();
|
||||||
|
(state.pages.len(), state.anchor)
|
||||||
|
};
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
self.page_label.set_text("- / -");
|
self.page_label.set_text("- / -");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let visible = self.visible_indices();
|
let visible = self.visible_indices();
|
||||||
let end = visible.last().copied().unwrap_or(state.anchor) + 1;
|
let end = visible.last().copied().unwrap_or(anchor) + 1;
|
||||||
if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage {
|
if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage {
|
||||||
self.page_label
|
self.page_label
|
||||||
.set_text(&format!("{}-{end} / {count}", state.anchor + 1));
|
.set_text(&format!("{}-{end} / {count}", anchor + 1));
|
||||||
} else {
|
} else {
|
||||||
self.page_label
|
self.page_label
|
||||||
.set_text(&format!("{} / {count}", state.anchor + 1));
|
.set_text(&format!("{} / {count}", anchor + 1));
|
||||||
}
|
}
|
||||||
self.previous_button.set_sensitive(state.anchor > 0);
|
self.previous_button.set_sensitive(anchor > 0);
|
||||||
self.next_button.set_sensitive(end < count);
|
self.next_button.set_sensitive(end < count);
|
||||||
self.shift_back_button.set_sensitive(state.anchor > 0);
|
self.shift_back_button.set_sensitive(anchor > 0);
|
||||||
self.shift_forward_button
|
self.shift_forward_button.set_sensitive(anchor + 1 < count);
|
||||||
.set_sensitive(state.anchor + 1 < count);
|
if (self.page_seek.value() - anchor as f64).abs() >= f64::EPSILON {
|
||||||
|
self.page_seek.set_value(anchor as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MediaViewerWindow;
|
||||||
|
|
||||||
|
impl MediaViewerWindow {
|
||||||
|
pub fn open(
|
||||||
|
application: >k::Application,
|
||||||
|
source: ArchiveSource,
|
||||||
|
kind: EntryKind,
|
||||||
|
sibling_navigator: Option<SiblingNavigator>,
|
||||||
|
) {
|
||||||
|
let title = source.display_name.clone();
|
||||||
|
let window = gtk::ApplicationWindow::builder()
|
||||||
|
.application(application)
|
||||||
|
.title(&title)
|
||||||
|
.default_width(1100)
|
||||||
|
.default_height(850)
|
||||||
|
.build();
|
||||||
|
let header = gtk::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
||||||
|
window.set_titlebar(Some(&header));
|
||||||
|
|
||||||
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
let stack = gtk::Stack::new();
|
||||||
|
stack.set_hexpand(true);
|
||||||
|
stack.set_vexpand(true);
|
||||||
|
let picture = page_picture();
|
||||||
|
stack.add_named(&picture, Some("image"));
|
||||||
|
let video = if kind == EntryKind::Video {
|
||||||
|
gtk::Video::for_filename(Some(&source.local_path))
|
||||||
|
} else {
|
||||||
|
gtk::Video::new()
|
||||||
|
};
|
||||||
|
video.set_autoplay(true);
|
||||||
|
video.set_hexpand(true);
|
||||||
|
video.set_vexpand(true);
|
||||||
|
stack.add_named(&video, Some("video"));
|
||||||
|
let status_label = gtk::Label::builder()
|
||||||
|
.label(&title)
|
||||||
|
.xalign(0.0)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(6)
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_end(8)
|
||||||
|
.build();
|
||||||
|
root.append(&stack);
|
||||||
|
root.append(&status_label);
|
||||||
|
window.set_child(Some(&root));
|
||||||
|
|
||||||
|
let controller = Rc::new(MediaViewerController {
|
||||||
|
window,
|
||||||
|
source,
|
||||||
|
picture,
|
||||||
|
video,
|
||||||
|
stack,
|
||||||
|
status_label,
|
||||||
|
sibling_navigator,
|
||||||
|
});
|
||||||
|
if let Some(stream) = controller.video.media_stream() {
|
||||||
|
let status_label = controller.status_label.clone();
|
||||||
|
stream.connect_error_notify(move |stream| {
|
||||||
|
if let Some(error) = stream.error() {
|
||||||
|
status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
controller.connect_signals();
|
||||||
|
controller.window.present();
|
||||||
|
match kind {
|
||||||
|
EntryKind::Image => controller.load_image(),
|
||||||
|
EntryKind::Video => {
|
||||||
|
controller.stack.set_visible_child_name("video");
|
||||||
|
if let Some(stream) = controller.video.media_stream() {
|
||||||
|
stream.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MediaViewerController {
|
||||||
|
window: gtk::ApplicationWindow,
|
||||||
|
source: ArchiveSource,
|
||||||
|
picture: gtk::Picture,
|
||||||
|
video: gtk::Video,
|
||||||
|
stack: gtk::Stack,
|
||||||
|
status_label: gtk::Label,
|
||||||
|
sibling_navigator: Option<SiblingNavigator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaViewerController {
|
||||||
|
fn connect_signals(self: &Rc<Self>) {
|
||||||
|
let keys = gtk::EventControllerKey::new();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
keys.connect_key_pressed(move |_, key, _, _| match key.to_unicode() {
|
||||||
|
Some('[') => {
|
||||||
|
controller.open_sibling(false);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
Some(']') => {
|
||||||
|
controller.open_sibling(true);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
Some(character) if character.eq_ignore_ascii_case(&'f') => {
|
||||||
|
controller.toggle_fullscreen();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ if key == gdk::Key::Escape && controller.window.is_fullscreen() => {
|
||||||
|
controller.window.unfullscreen();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
}
|
||||||
|
_ => glib::Propagation::Proceed,
|
||||||
|
});
|
||||||
|
self.window.add_controller(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_sibling(&self, forward: bool) {
|
||||||
|
if let Some(navigate) = &self.sibling_navigator
|
||||||
|
&& navigate(forward)
|
||||||
|
{
|
||||||
|
if let Some(stream) = self.video.media_stream() {
|
||||||
|
stream.pause();
|
||||||
|
}
|
||||||
|
self.window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_fullscreen(&self) {
|
||||||
|
if self.window.is_fullscreen() {
|
||||||
|
self.window.unfullscreen();
|
||||||
|
} else {
|
||||||
|
self.window.fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_image(self: &Rc<Self>) {
|
||||||
|
self.stack.set_visible_child_name("image");
|
||||||
|
self.status_label.set_text("이미지를 읽는 중...");
|
||||||
|
let path = self.source.local_path.clone();
|
||||||
|
let display_name = self.source.display_name.clone();
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result =
|
||||||
|
decode_image_file(&path, &display_name).map_err(|error| format!("{error:#}"));
|
||||||
|
let _ = sender.send(result);
|
||||||
|
});
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
||||||
|
match receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(controller) = controller.upgrade() {
|
||||||
|
controller.finish_image_load(result);
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_image_load(&self, result: Result<DecodedPage, String>) {
|
||||||
|
match result {
|
||||||
|
Ok(page) => {
|
||||||
|
let bytes = glib::Bytes::from_owned(page.rgba);
|
||||||
|
let texture = gdk::MemoryTexture::new(
|
||||||
|
page.width as i32,
|
||||||
|
page.height as i32,
|
||||||
|
gdk::MemoryFormat::R8g8b8a8,
|
||||||
|
&bytes,
|
||||||
|
page.width as usize * 4,
|
||||||
|
);
|
||||||
|
self.picture.set_paintable(Some(&texture));
|
||||||
|
self.status_label.set_text(&self.source.display_name);
|
||||||
|
}
|
||||||
|
Err(error) => self.status_label.set_text(&error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -918,6 +1155,10 @@ fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Ve
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn page_seek_index(value: f64, count: usize) -> Option<usize> {
|
||||||
|
(count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -960,4 +1201,11 @@ mod tests {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_seek_rounds_and_clamps_to_archive() {
|
||||||
|
assert_eq!(page_seek_index(3.6, 10), Some(4));
|
||||||
|
assert_eq!(page_seek_index(20.0, 10), Some(9));
|
||||||
|
assert_eq!(page_seek_index(0.0, 0), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
240
src/window.rs
240
src/window.rs
|
|
@ -1,4 +1,5 @@
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -17,7 +18,7 @@ use crate::source::{
|
||||||
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
|
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
|
||||||
parent_within_root, root_file, validate_profile,
|
parent_within_root, root_file, validate_profile,
|
||||||
};
|
};
|
||||||
use crate::viewer::ViewerWindow;
|
use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow};
|
||||||
use crate::zip_cache::ZipCache;
|
use crate::zip_cache::ZipCache;
|
||||||
|
|
||||||
pub struct ComicWindow {
|
pub struct ComicWindow {
|
||||||
|
|
@ -239,6 +240,13 @@ impl BrowserController {
|
||||||
self.list_box
|
self.list_box
|
||||||
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
||||||
|
|
||||||
|
let context_click = gtk::GestureClick::builder().button(3).build();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
context_click.connect_pressed(move |_, _, _, y| {
|
||||||
|
controller.show_context_menu(y as i32);
|
||||||
|
});
|
||||||
|
self.list_box.add_controller(context_click);
|
||||||
|
|
||||||
let key_controller = gtk::EventControllerKey::new();
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
key_controller
|
key_controller
|
||||||
|
|
@ -480,14 +488,71 @@ impl BrowserController {
|
||||||
self.search_entry.set_text("");
|
self.search_entry.set_text("");
|
||||||
self.refresh();
|
self.refresh();
|
||||||
}
|
}
|
||||||
EntryKind::ZipArchive => self.open_archive(entry),
|
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => {
|
||||||
|
let siblings = {
|
||||||
|
let state = self.state.borrow();
|
||||||
|
let mut entries = state
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.kind.is_viewable())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
sort_entries(&mut entries, state.sort_field, state.sort_direction);
|
||||||
|
entries
|
||||||
|
};
|
||||||
|
let siblings = Rc::new(siblings);
|
||||||
|
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) {
|
||||||
|
self.open_viewable(entry, siblings, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntryKind::OtherFile => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_archive(self: &Rc<Self>, entry: BrowserEntry) {
|
fn open_viewable(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
entry: BrowserEntry,
|
||||||
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
|
current: usize,
|
||||||
|
) {
|
||||||
|
match entry.kind {
|
||||||
|
EntryKind::ZipArchive => self.open_archive(entry, siblings, current),
|
||||||
|
EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sibling_navigator(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
|
current: usize,
|
||||||
|
) -> SiblingNavigator {
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
Rc::new(move |forward| {
|
||||||
|
let Some(controller) = controller.upgrade() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(next) = sibling_index(current, siblings.len(), forward) else {
|
||||||
|
controller
|
||||||
|
.status_label
|
||||||
|
.set_text("더 이상 이동할 파일이 없습니다.");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next);
|
||||||
|
true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_archive(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
entry: BrowserEntry,
|
||||||
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
|
current: usize,
|
||||||
|
) {
|
||||||
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
|
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
|
||||||
let progress_label = self.status_label.clone();
|
let progress_label = self.status_label.clone();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
|
let navigator = self.sibling_navigator(siblings, current);
|
||||||
self.zip_cache.prepare(
|
self.zip_cache.prepare(
|
||||||
entry,
|
entry,
|
||||||
move |current, total| {
|
move |current, total| {
|
||||||
|
|
@ -505,6 +570,7 @@ impl BrowserController {
|
||||||
source,
|
source,
|
||||||
Rc::clone(&controller.database),
|
Rc::clone(&controller.database),
|
||||||
controller.config.borrow().fast_image_loading,
|
controller.config.borrow().fast_image_loading,
|
||||||
|
Some(navigator),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||||
|
|
@ -512,6 +578,138 @@ impl BrowserController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_media(
|
||||||
|
self: &Rc<Self>,
|
||||||
|
entry: BrowserEntry,
|
||||||
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
|
current: usize,
|
||||||
|
) {
|
||||||
|
let kind = entry.kind;
|
||||||
|
self.status_label
|
||||||
|
.set_text("미디어 파일 다운로드 준비 중...");
|
||||||
|
let progress_label = self.status_label.clone();
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
let navigator = self.sibling_navigator(siblings, current);
|
||||||
|
self.zip_cache.prepare_media(
|
||||||
|
entry,
|
||||||
|
move |current, total| {
|
||||||
|
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||||
|
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
|
||||||
|
},
|
||||||
|
move |result| match result {
|
||||||
|
Ok(source) => {
|
||||||
|
controller.status_label.set_text("다운로드 완료");
|
||||||
|
let Some(application) = controller.window.application() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
MediaViewerWindow::open(&application, source, kind, Some(navigator));
|
||||||
|
}
|
||||||
|
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_context_menu(self: &Rc<Self>, y: i32) {
|
||||||
|
let Some(row) = self.list_box.row_at_y(y) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(entry) = self
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.visible_entries
|
||||||
|
.get(row.index() as usize)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.list_box.select_row(Some(&row));
|
||||||
|
let popover = gtk::Popover::new();
|
||||||
|
popover.set_parent(&row);
|
||||||
|
let download_button = gtk::Button::with_label("다운로드...");
|
||||||
|
download_button.set_margin_top(6);
|
||||||
|
download_button.set_margin_bottom(6);
|
||||||
|
download_button.set_margin_start(6);
|
||||||
|
download_button.set_margin_end(6);
|
||||||
|
popover.set_child(Some(&download_button));
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
let popover_to_close = popover.clone();
|
||||||
|
download_button.connect_clicked(move |_| {
|
||||||
|
popover_to_close.popdown();
|
||||||
|
controller.choose_download_destination(entry.clone());
|
||||||
|
});
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_download_destination(self: &Rc<Self>, entry: BrowserEntry) {
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("다운로드 위치 선택")
|
||||||
|
.initial_name(&entry.name)
|
||||||
|
.build();
|
||||||
|
let controller = Rc::downgrade(self);
|
||||||
|
let parent = self.window.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
let result = dialog.save_future(Some(&parent)).await;
|
||||||
|
let Some(controller) = controller.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(destination) if destination.is_native() => {
|
||||||
|
controller.download_remote_file(entry, destination);
|
||||||
|
}
|
||||||
|
Ok(_) => controller
|
||||||
|
.status_label
|
||||||
|
.set_text("로컬 파일 시스템 위치를 선택해 주세요."),
|
||||||
|
Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {}
|
||||||
|
Err(error) => controller
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_remote_file(&self, entry: BrowserEntry, destination: gio::File) {
|
||||||
|
let Some(final_path) = destination.path() else {
|
||||||
|
self.status_label
|
||||||
|
.set_text("로컬 파일 시스템 위치를 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let part_path = download_part_path(&final_path);
|
||||||
|
let source = gio::File::for_uri(&entry.uri);
|
||||||
|
let part_file = gio::File::for_path(&part_path);
|
||||||
|
let progress_label = self.status_label.clone();
|
||||||
|
let result_label = self.status_label.clone();
|
||||||
|
let expected_size = entry.size;
|
||||||
|
source.copy_async(
|
||||||
|
&part_file,
|
||||||
|
gio::FileCopyFlags::OVERWRITE,
|
||||||
|
glib::Priority::DEFAULT,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
Some(Box::new(move |current, total| {
|
||||||
|
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||||
|
progress_label.set_text(&format!("{} 다운로드 중... {percent}%", entry.name));
|
||||||
|
})),
|
||||||
|
move |result| {
|
||||||
|
let completed = result.map_err(anyhow::Error::from).and_then(|()| {
|
||||||
|
let actual_size = fs::metadata(&part_path)?.len();
|
||||||
|
if expected_size.is_some_and(|expected| expected != actual_size) {
|
||||||
|
anyhow::bail!("다운로드 크기가 원본과 다릅니다.");
|
||||||
|
}
|
||||||
|
fs::rename(&part_path, &final_path)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
if let Err(error) = completed {
|
||||||
|
let _ = fs::remove_file(&part_path);
|
||||||
|
result_label.set_text(&format!("다운로드 실패: {error:#}"));
|
||||||
|
} else {
|
||||||
|
result_label.set_text(&format!("다운로드 완료: {}", final_path.display()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn set_fast_image_loading(&self, switch: >k::Switch) {
|
fn set_fast_image_loading(&self, switch: >k::Switch) {
|
||||||
let active = switch.is_active();
|
let active = switch.is_active();
|
||||||
let previous = self.config.borrow().fast_image_loading;
|
let previous = self.config.borrow().fast_image_loading;
|
||||||
|
|
@ -752,6 +950,9 @@ fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
|
||||||
let icon = match entry.kind {
|
let icon = match entry.kind {
|
||||||
EntryKind::Directory => "folder-symbolic",
|
EntryKind::Directory => "folder-symbolic",
|
||||||
EntryKind::ZipArchive => "package-x-generic-symbolic",
|
EntryKind::ZipArchive => "package-x-generic-symbolic",
|
||||||
|
EntryKind::Image => "image-x-generic-symbolic",
|
||||||
|
EntryKind::Video => "video-x-generic-symbolic",
|
||||||
|
EntryKind::OtherFile => "text-x-generic-symbolic",
|
||||||
};
|
};
|
||||||
content.append(>k::Image::from_icon_name(icon));
|
content.append(>k::Image::from_icon_name(icon));
|
||||||
content.append(
|
content.append(
|
||||||
|
|
@ -790,6 +991,23 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt
|
||||||
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
|
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sibling_index(current: usize, count: usize, forward: bool) -> Option<usize> {
|
||||||
|
if forward {
|
||||||
|
(current + 1 < count).then_some(current + 1)
|
||||||
|
} else {
|
||||||
|
current.checked_sub(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_part_path(final_path: &std::path::Path) -> PathBuf {
|
||||||
|
final_path.with_file_name(format!(
|
||||||
|
"{}.part",
|
||||||
|
final_path
|
||||||
|
.file_name()
|
||||||
|
.map_or_else(|| "download".into(), |name| name.to_string_lossy())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -819,4 +1037,20 @@ mod tests {
|
||||||
assert_eq!(find_prefix_index(&entries, "b", 2), Some(2));
|
assert_eq!(find_prefix_index(&entries, "b", 2), Some(2));
|
||||||
assert_eq!(find_prefix_index(&entries, "b", 3), Some(1));
|
assert_eq!(find_prefix_index(&entries, "b", 3), Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sibling_navigation_stops_at_boundaries() {
|
||||||
|
assert_eq!(sibling_index(1, 3, false), Some(0));
|
||||||
|
assert_eq!(sibling_index(1, 3, true), Some(2));
|
||||||
|
assert_eq!(sibling_index(0, 3, false), None);
|
||||||
|
assert_eq!(sibling_index(2, 3, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_download_uses_distinct_suffix() {
|
||||||
|
assert_eq!(
|
||||||
|
download_part_path(std::path::Path::new("/tmp/만화.zip")),
|
||||||
|
PathBuf::from("/tmp/만화.zip.part")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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>>,
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +76,7 @@ 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,
|
||||||
}));
|
}));
|
||||||
|
|
@ -82,24 +84,50 @@ impl ZipCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
|
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.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,
|
||||||
source_uri: String,
|
entry: BrowserEntry,
|
||||||
expected_size: Option<u64>,
|
validate_zip: bool,
|
||||||
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 temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() {
|
let suffix = Path::new(&entry.name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.map_or_else(
|
||||||
|
|| ".part".to_owned(),
|
||||||
|
|extension| format!(".part.{extension}"),
|
||||||
|
);
|
||||||
|
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
|
||||||
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()));
|
||||||
|
|
@ -108,6 +136,7 @@ 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,
|
||||||
|
|
@ -116,10 +145,14 @@ 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_download(retained.as_ref(), expected_size)?;
|
validate_size(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,
|
source_uri: entry.uri,
|
||||||
|
display_name: entry.name,
|
||||||
_temporary: Some(retained),
|
_temporary: Some(retained),
|
||||||
_pin: None,
|
_pin: None,
|
||||||
})
|
})
|
||||||
|
|
@ -143,7 +176,7 @@ 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)));
|
callback(Ok(self.lease(final_path, entry.uri, entry.name)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = fs::remove_file(&final_path);
|
let _ = fs::remove_file(&final_path);
|
||||||
|
|
@ -168,7 +201,7 @@ impl ZipCache {
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
cache.enforce_quota(&final_path)?;
|
cache.enforce_quota(&final_path)?;
|
||||||
Ok(cache.lease(final_path, entry.uri))
|
Ok(cache.lease(final_path, entry.uri, entry.name))
|
||||||
});
|
});
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
let _ = fs::remove_file(&part_path);
|
let _ = fs::remove_file(&part_path);
|
||||||
|
|
@ -205,11 +238,17 @@ impl ZipCache {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lease(self: &Rc<Self>, path: PathBuf, source_uri: String) -> ArchiveSource {
|
fn lease(
|
||||||
|
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,
|
||||||
|
|
@ -224,6 +263,11 @@ 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
|
||||||
|
|
@ -232,6 +276,10 @@ fn validate_download(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(())
|
||||||
|
|
@ -307,7 +355,11 @@ mod tests {
|
||||||
pinned: RefCell::new(HashMap::new()),
|
pinned: RefCell::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let lease = cache.lease(first.clone(), "file:///first.zip".into());
|
let lease = cache.lease(
|
||||||
|
first.clone(),
|
||||||
|
"file:///first.zip".into(),
|
||||||
|
"first.zip".into(),
|
||||||
|
);
|
||||||
cache.enforce_quota(&second).unwrap();
|
cache.enforce_quota(&second).unwrap();
|
||||||
assert!(first.exists());
|
assert!(first.exists());
|
||||||
assert!(second.exists());
|
assert!(second.exists());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue