Release v0.2.1
This commit is contained in:
parent
ffa8bc93ba
commit
048df6355e
10 changed files with 1467 additions and 254 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
</provides>
|
</provides>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.2.1" date="2026-07-22" />
|
||||||
<release version="0.2.0" date="2026-07-21" />
|
<release version="0.2.0" date="2026-07-21" />
|
||||||
<release version="0.1.4" date="2026-07-21" />
|
<release version="0.1.4" date="2026-07-21" />
|
||||||
<release version="0.1.3" date="2026-07-21" />
|
<release version="0.1.3" date="2026-07-21" />
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,18 @@ 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·이미지·동영상 열기
|
||||||
|
- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
|
||||||
|
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
|
||||||
|
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
|
||||||
|
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
|
||||||
|
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
|
||||||
|
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
|
||||||
|
|
||||||
|
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
|
||||||
|
|
||||||
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
||||||
|
|
||||||
- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256`
|
- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256`
|
||||||
- `dist/Comicviewer-latest-x86_64.tar.gz`
|
- `dist/Comicviewer-latest-x86_64.tar.gz`
|
||||||
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
|
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
|
||||||
|
|
||||||
|
|
@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git add Cargo.toml Cargo.lock data packaging README.md docs
|
git add Cargo.toml Cargo.lock data packaging README.md docs
|
||||||
git commit -m "Prepare v0.2.0 release"
|
git commit -m "Prepare v0.2.1 release"
|
||||||
git tag -a v0.2.0 -m "Comicviewer v0.2.0"
|
git tag -a v0.2.1 -m "Comicviewer v0.2.1"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v0.2.0
|
git push origin v0.2.1
|
||||||
```
|
```
|
||||||
|
|
||||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||||
|
|
|
||||||
131
src/archive.rs
131
src/archive.rs
|
|
@ -1,9 +1,13 @@
|
||||||
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::ImageReader;
|
use image::codecs::gif::GifDecoder;
|
||||||
|
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_with_case;
|
||||||
|
|
@ -11,6 +15,9 @@ use crate::browser::natural_cmp_with_case;
|
||||||
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 {
|
||||||
|
|
@ -21,7 +28,13 @@ 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, case_insensitive: bool) -> Result<Vec<ArchivePage>> {
|
||||||
|
|
@ -90,11 +103,33 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
|
||||||
let reader = ImageReader::new(Cursor::new(encoded))
|
let reader = ImageReader::new(Cursor::new(encoded))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
|
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
|
||||||
|
let format = reader.format();
|
||||||
let (width, height) = reader
|
let (width, height) = reader
|
||||||
.into_dimensions()
|
.into_dimensions()
|
||||||
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
|
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
|
||||||
validate_dimensions(width, height, name)?;
|
validate_dimensions(width, height, name)?;
|
||||||
|
|
||||||
|
let animated = match format {
|
||||||
|
Some(ImageFormat::Gif) => {
|
||||||
|
let decoder = GifDecoder::new(Cursor::new(encoded))
|
||||||
|
.with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?;
|
||||||
|
let loop_count = animation_loop_count(decoder.loop_count());
|
||||||
|
Some((decoder.into_frames(), loop_count))
|
||||||
|
}
|
||||||
|
Some(ImageFormat::WebP) => {
|
||||||
|
let decoder = WebPDecoder::new(Cursor::new(encoded))
|
||||||
|
.with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?;
|
||||||
|
decoder.has_animation().then(|| {
|
||||||
|
let loop_count = animation_loop_count(decoder.loop_count());
|
||||||
|
(decoder.into_frames(), loop_count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some((frames, loop_count)) = animated {
|
||||||
|
return decoded_animation(width, height, frames, loop_count, name);
|
||||||
|
}
|
||||||
|
|
||||||
let image = image::load_from_memory(encoded)
|
let image = image::load_from_memory(encoded)
|
||||||
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
|
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
|
||||||
let rgba = image.into_rgba8();
|
let rgba = image.into_rgba8();
|
||||||
|
|
@ -102,10 +137,69 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
|
||||||
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
|
||||||
|
|
@ -188,7 +282,7 @@ 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.rgba.len(), 2 * 3 * 4);
|
assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4);
|
||||||
|
|
||||||
std::fs::remove_file(path).unwrap();
|
std::fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +296,37 @@ mod tests {
|
||||||
|
|
||||||
let decoded = decode_image_file(&path, "photo.png").unwrap();
|
let decoded = decode_image_file(&path, "photo.png").unwrap();
|
||||||
assert_eq!((decoded.width, decoded.height), (4, 5));
|
assert_eq!((decoded.width, decoded.height), (4, 5));
|
||||||
assert_eq!(decoded.rgba.len(), 4 * 5 * 4);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ impl EntryKind {
|
||||||
pub fn is_viewable(self) -> bool {
|
pub fn is_viewable(self) -> bool {
|
||||||
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
|
matches!(self, Self::ZipArchive | Self::Image | Self::Video)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_media(self) -> bool {
|
|
||||||
matches!(self, Self::Image | Self::Video)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -229,10 +225,6 @@ mod tests {
|
||||||
assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video);
|
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("movie.mkv")), EntryKind::Video);
|
||||||
assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile);
|
assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile);
|
||||||
assert!(EntryKind::Image.is_media());
|
|
||||||
assert!(EntryKind::Video.is_media());
|
|
||||||
assert!(!EntryKind::ZipArchive.is_media());
|
|
||||||
assert!(!EntryKind::OtherFile.is_media());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
974
src/viewer.rs
974
src/viewer.rs
File diff suppressed because it is too large
Load diff
229
src/window.rs
229
src/window.rs
|
|
@ -21,8 +21,9 @@ use crate::source::{
|
||||||
mount_operation, parent_within_root, root_file, validate_profile,
|
mount_operation, parent_within_root, root_file, validate_profile,
|
||||||
};
|
};
|
||||||
use crate::viewer::{
|
use crate::viewer::{
|
||||||
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
|
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
|
||||||
SiblingTarget, ViewModeControl, ViewerWindow,
|
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
|
||||||
|
set_viewer_header,
|
||||||
};
|
};
|
||||||
use crate::zip_cache::ZipCache;
|
use crate::zip_cache::ZipCache;
|
||||||
|
|
||||||
|
|
@ -51,6 +52,8 @@ impl ComicWindow {
|
||||||
header.set_title_widget(Some(>k::Label::new(Some("Comicviewer"))));
|
header.set_title_widget(Some(>k::Label::new(Some("Comicviewer"))));
|
||||||
let about_button = gtk::Button::with_label("정보");
|
let about_button = gtk::Button::with_label("정보");
|
||||||
header.pack_end(&about_button);
|
header.pack_end(&about_button);
|
||||||
|
let clear_cache_button = gtk::Button::with_label("캐시 비우기");
|
||||||
|
header.pack_end(&clear_cache_button);
|
||||||
window.set_titlebar(Some(&header));
|
window.set_titlebar(Some(&header));
|
||||||
|
|
||||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||||
|
|
@ -128,6 +131,7 @@ impl ComicWindow {
|
||||||
let controller = Rc::new(BrowserController {
|
let controller = Rc::new(BrowserController {
|
||||||
window,
|
window,
|
||||||
about_button,
|
about_button,
|
||||||
|
clear_cache_button,
|
||||||
location_dropdown,
|
location_dropdown,
|
||||||
edit_location_button,
|
edit_location_button,
|
||||||
delete_location_button,
|
delete_location_button,
|
||||||
|
|
@ -155,6 +159,7 @@ impl ComicWindow {
|
||||||
config_path,
|
config_path,
|
||||||
database: Rc::new(database),
|
database: Rc::new(database),
|
||||||
zip_cache,
|
zip_cache,
|
||||||
|
listing_task: RefCell::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.rebuild_profiles();
|
controller.rebuild_profiles();
|
||||||
|
|
@ -193,6 +198,7 @@ struct BrowserState {
|
||||||
struct BrowserController {
|
struct BrowserController {
|
||||||
window: gtk::ApplicationWindow,
|
window: gtk::ApplicationWindow,
|
||||||
about_button: gtk::Button,
|
about_button: gtk::Button,
|
||||||
|
clear_cache_button: gtk::Button,
|
||||||
location_dropdown: gtk::DropDown,
|
location_dropdown: gtk::DropDown,
|
||||||
edit_location_button: gtk::Button,
|
edit_location_button: gtk::Button,
|
||||||
delete_location_button: gtk::Button,
|
delete_location_button: gtk::Button,
|
||||||
|
|
@ -207,6 +213,7 @@ struct BrowserController {
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
database: Rc<Database>,
|
database: Rc<Database>,
|
||||||
zip_cache: Rc<ZipCache>,
|
zip_cache: Rc<ZipCache>,
|
||||||
|
listing_task: RefCell<Option<glib::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserController {
|
impl BrowserController {
|
||||||
|
|
@ -326,6 +333,9 @@ impl BrowserController {
|
||||||
fast_loading_switch.connect_active_notify(move |switch| {
|
fast_loading_switch.connect_active_notify(move |switch| {
|
||||||
controller.set_fast_image_loading(switch);
|
controller.set_fast_image_loading(switch);
|
||||||
});
|
});
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
self.clear_cache_button
|
||||||
|
.connect_clicked(move |_| controller.show_clear_cache_dialog());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profiles(&self) -> Vec<LocationProfile> {
|
fn profiles(&self) -> Vec<LocationProfile> {
|
||||||
|
|
@ -388,6 +398,7 @@ impl BrowserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_favorites(&self) {
|
fn show_favorites(&self) {
|
||||||
|
self.cancel_listing();
|
||||||
self.edit_location_button.set_sensitive(false);
|
self.edit_location_button.set_sensitive(false);
|
||||||
self.delete_location_button.set_sensitive(false);
|
self.delete_location_button.set_sensitive(false);
|
||||||
self.path_entry.set_text("즐겨찾기");
|
self.path_entry.set_text("즐겨찾기");
|
||||||
|
|
@ -439,12 +450,29 @@ impl BrowserController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.cancel_listing();
|
||||||
self.status_label.set_text("즐겨찾기에 연결하는 중...");
|
self.status_label.set_text("즐겨찾기에 연결하는 중...");
|
||||||
let operation = mount_operation(&favorite.profile, &self.window);
|
let operation = mount_operation(&favorite.profile, &self.window);
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
glib::MainContext::default().spawn_local(async move {
|
let task = glib::MainContext::default().spawn_local(async move {
|
||||||
let file = gio::File::for_uri(&favorite.uri);
|
let file = gio::File::for_uri(&favorite.uri);
|
||||||
let result = ensure_mounted(&file, &favorite.profile, &operation).await;
|
let result = ensure_mounted(&file, &favorite.profile, &operation).await;
|
||||||
|
let folder_entries = if result.is_ok() {
|
||||||
|
if let Some(parent) = file.parent() {
|
||||||
|
enumerate_directory(
|
||||||
|
&favorite.profile,
|
||||||
|
parent.uri().as_str(),
|
||||||
|
&operation,
|
||||||
|
|_| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let Some(controller) = controller.upgrade() else {
|
let Some(controller) = controller.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -459,18 +487,36 @@ impl BrowserController {
|
||||||
.set_text("이 즐겨찾기 파일 형식은 열 수 없습니다.");
|
.set_text("이 즐겨찾기 파일 형식은 열 수 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let entry = BrowserEntry {
|
let entry = folder_entries
|
||||||
uri: favorite.uri,
|
.as_ref()
|
||||||
name: favorite.name,
|
.and_then(|entries| entries.iter().find(|entry| entry.uri == favorite.uri))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| BrowserEntry {
|
||||||
|
uri: favorite.uri.clone(),
|
||||||
|
name: favorite.name.clone(),
|
||||||
kind,
|
kind,
|
||||||
size: None,
|
size: None,
|
||||||
modified: None,
|
modified: None,
|
||||||
created: None,
|
created: None,
|
||||||
etag: None,
|
etag: None,
|
||||||
};
|
});
|
||||||
let siblings = Rc::new(vec![entry.clone()]);
|
let siblings = folder_entries.map_or_else(
|
||||||
|
|| vec![entry.clone()],
|
||||||
|
|entries| {
|
||||||
|
let state = controller.state.borrow();
|
||||||
|
sorted_navigable_entries(
|
||||||
|
&entries,
|
||||||
|
kind,
|
||||||
|
state.sort_field,
|
||||||
|
state.sort_direction,
|
||||||
|
controller.config.borrow().case_insensitive_sort,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let siblings = Rc::new(siblings);
|
||||||
controller.open_viewable(entry, siblings, favorite.profile, None);
|
controller.open_viewable(entry, siblings, favorite.profile, None);
|
||||||
});
|
});
|
||||||
|
self.listing_task.replace(Some(task));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_entered_path(self: &Rc<Self>) {
|
fn open_entered_path(self: &Rc<Self>) {
|
||||||
|
|
@ -512,6 +558,7 @@ impl BrowserController {
|
||||||
self.show_favorites();
|
self.show_favorites();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.cancel_listing();
|
||||||
let (profile, uri, generation) = {
|
let (profile, uri, generation) = {
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
state.generation = state.generation.wrapping_add(1);
|
state.generation = state.generation.wrapping_add(1);
|
||||||
|
|
@ -532,7 +579,7 @@ impl BrowserController {
|
||||||
let operation = mount_operation(&profile, &self.window);
|
let operation = mount_operation(&profile, &self.window);
|
||||||
let progress_controller = Rc::downgrade(self);
|
let progress_controller = Rc::downgrade(self);
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
glib::MainContext::default().spawn_local(async move {
|
let task = glib::MainContext::default().spawn_local(async move {
|
||||||
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
|
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
|
||||||
let Some(controller) = progress_controller.upgrade() else {
|
let Some(controller) = progress_controller.upgrade() else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -561,6 +608,13 @@ impl BrowserController {
|
||||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
self.listing_task.replace(Some(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_listing(&self) {
|
||||||
|
if let Some(task) = self.listing_task.borrow_mut().take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self) {
|
fn render(&self) {
|
||||||
|
|
@ -733,19 +787,7 @@ impl BrowserController {
|
||||||
match entry.kind {
|
match entry.kind {
|
||||||
EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
|
EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
|
||||||
EntryKind::Image | EntryKind::Video => {
|
EntryKind::Image | EntryKind::Video => {
|
||||||
let media_siblings = Rc::new(
|
self.open_media(entry, siblings, profile, reuse_window)
|
||||||
siblings
|
|
||||||
.iter()
|
|
||||||
.filter(|sibling| sibling.kind.is_media())
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
);
|
|
||||||
if media_siblings
|
|
||||||
.iter()
|
|
||||||
.any(|sibling| sibling.uri == entry.uri)
|
|
||||||
{
|
|
||||||
self.open_media(entry, media_siblings, profile, reuse_window);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -773,14 +815,22 @@ impl BrowserController {
|
||||||
});
|
});
|
||||||
|
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
|
let navigate_siblings = Rc::clone(&siblings);
|
||||||
|
let navigate_current_uri = current_uri.clone();
|
||||||
|
let navigate_profile = profile.clone();
|
||||||
let navigate = Rc::new(
|
let navigate = Rc::new(
|
||||||
move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| {
|
move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| {
|
||||||
let Some(controller) = controller.upgrade() else {
|
let Some(controller) = controller.upgrade() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let siblings =
|
let siblings = controller.navigation_siblings(
|
||||||
controller.navigation_siblings(¤t_uri, current_kind, &siblings);
|
&navigate_current_uri,
|
||||||
let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri)
|
current_kind,
|
||||||
|
&navigate_siblings,
|
||||||
|
);
|
||||||
|
let Some(current) = siblings
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.uri == navigate_current_uri)
|
||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -790,13 +840,47 @@ impl BrowserController {
|
||||||
controller.open_viewable(
|
controller.open_viewable(
|
||||||
siblings[next].clone(),
|
siblings[next].clone(),
|
||||||
Rc::clone(&siblings),
|
Rc::clone(&siblings),
|
||||||
profile.clone(),
|
navigate_profile.clone(),
|
||||||
Some(reuse_window),
|
Some(reuse_window),
|
||||||
);
|
);
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
SiblingNavigator::new(position, navigate)
|
let files_controller = Rc::downgrade(self);
|
||||||
|
let files_current_uri = current_uri.clone();
|
||||||
|
let files_fallback = Rc::clone(&siblings);
|
||||||
|
let files = Rc::new(move || {
|
||||||
|
let Some(controller) = files_controller.upgrade() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
controller
|
||||||
|
.navigation_siblings(&files_current_uri, current_kind, &files_fallback)
|
||||||
|
.iter()
|
||||||
|
.map(|entry| SiblingFile {
|
||||||
|
uri: entry.uri.clone(),
|
||||||
|
name: entry.name.clone(),
|
||||||
|
kind: entry.kind,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
let actions_controller = Rc::downgrade(self);
|
||||||
|
let actions_current_uri = current_uri;
|
||||||
|
let actions_fallback = Rc::clone(&siblings);
|
||||||
|
let actions_profile = profile;
|
||||||
|
let actions = Rc::new(move |uri: &str| {
|
||||||
|
let controller = actions_controller.upgrade()?;
|
||||||
|
let entries = controller.navigation_siblings(
|
||||||
|
&actions_current_uri,
|
||||||
|
current_kind,
|
||||||
|
&actions_fallback,
|
||||||
|
);
|
||||||
|
let entry = entries.iter().find(|entry| entry.uri == uri)?;
|
||||||
|
Some(SiblingFileActions {
|
||||||
|
favorite: controller.favorite_control(entry, &actions_profile),
|
||||||
|
download: controller.download_control(entry),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
SiblingNavigator::new(position, navigate, files, actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn navigation_siblings(
|
fn navigation_siblings(
|
||||||
|
|
@ -814,8 +898,9 @@ impl BrowserController {
|
||||||
state.sort_direction,
|
state.sort_direction,
|
||||||
case_insensitive,
|
case_insensitive,
|
||||||
);
|
);
|
||||||
|
let browsing_favorites = state.browsing_favorites;
|
||||||
drop(state);
|
drop(state);
|
||||||
if entries.iter().any(|entry| entry.uri == current_uri) {
|
if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) {
|
||||||
Rc::new(entries)
|
Rc::new(entries)
|
||||||
} else {
|
} else {
|
||||||
Rc::clone(fallback)
|
Rc::clone(fallback)
|
||||||
|
|
@ -846,7 +931,14 @@ impl BrowserController {
|
||||||
let percent = if total > 0 { current * 100 / total } else { 0 };
|
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||||
progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%"));
|
progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%"));
|
||||||
},
|
},
|
||||||
move |result| match result {
|
move |result| {
|
||||||
|
if reuse_window
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|window| !window.is_visible())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
Ok(source) => {
|
Ok(source) => {
|
||||||
controller.status_label.set_text("다운로드 완료");
|
controller.status_label.set_text("다운로드 완료");
|
||||||
result_label.set_text("ZIP 파일을 여는 중...");
|
result_label.set_text("ZIP 파일을 여는 중...");
|
||||||
|
|
@ -874,6 +966,7 @@ impl BrowserController {
|
||||||
controller.status_label.set_text(&message);
|
controller.status_label.set_text(&message);
|
||||||
result_label.set_text(&message);
|
result_label.set_text(&message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -904,7 +997,14 @@ impl BrowserController {
|
||||||
let percent = if total > 0 { current * 100 / total } else { 0 };
|
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||||
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
|
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
|
||||||
},
|
},
|
||||||
move |result| match result {
|
move |result| {
|
||||||
|
if reuse_window
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|window| !window.is_visible())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
Ok(source) => {
|
Ok(source) => {
|
||||||
controller.status_label.set_text("다운로드 완료");
|
controller.status_label.set_text("다운로드 완료");
|
||||||
result_label.set_text("미디어 파일을 여는 중...");
|
result_label.set_text("미디어 파일을 여는 중...");
|
||||||
|
|
@ -929,6 +1029,7 @@ impl BrowserController {
|
||||||
controller.status_label.set_text(&message);
|
controller.status_label.set_text(&message);
|
||||||
result_label.set_text(&message);
|
result_label.set_text(&message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1252,6 +1353,60 @@ impl BrowserController {
|
||||||
dialog.present();
|
dialog.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_clear_cache_dialog(self: &Rc<Self>) {
|
||||||
|
let dialog = gtk::Window::builder()
|
||||||
|
.title("캐시 비우기")
|
||||||
|
.transient_for(&self.window)
|
||||||
|
.modal(true)
|
||||||
|
.resizable(false)
|
||||||
|
.default_width(440)
|
||||||
|
.build();
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||||
|
content.set_margin_top(18);
|
||||||
|
content.set_margin_bottom(12);
|
||||||
|
content.set_margin_start(18);
|
||||||
|
content.set_margin_end(18);
|
||||||
|
let message = gtk::Label::new(Some(
|
||||||
|
"다운로드한 ZIP 캐시를 삭제하시겠습니까?\n현재 열려 있는 ZIP과 읽기 기록은 유지됩니다.",
|
||||||
|
));
|
||||||
|
message.set_wrap(true);
|
||||||
|
message.set_xalign(0.0);
|
||||||
|
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||||
|
actions.set_halign(gtk::Align::End);
|
||||||
|
let cancel_button = gtk::Button::with_label("취소");
|
||||||
|
let clear_button = gtk::Button::with_label("캐시 삭제");
|
||||||
|
clear_button.add_css_class("destructive-action");
|
||||||
|
actions.append(&cancel_button);
|
||||||
|
actions.append(&clear_button);
|
||||||
|
content.append(&message);
|
||||||
|
content.append(&actions);
|
||||||
|
dialog.set_child(Some(&content));
|
||||||
|
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
cancel_button.connect_clicked(move |_| dialog_to_close.close());
|
||||||
|
let controller = Rc::clone(self);
|
||||||
|
let dialog_to_close = dialog.clone();
|
||||||
|
clear_button.connect_clicked(move |_| match controller.zip_cache.clear_persistent() {
|
||||||
|
Ok(report) => {
|
||||||
|
let removed_mib = report.removed_bytes as f64 / (1024.0 * 1024.0);
|
||||||
|
let retained = if report.retained_open_files == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(", 열려 있어 유지한 파일 {}개", report.retained_open_files)
|
||||||
|
};
|
||||||
|
controller.status_label.set_text(&format!(
|
||||||
|
"ZIP 캐시 {}개({removed_mib:.1} MiB)를 삭제했습니다{retained}.",
|
||||||
|
report.removed_files
|
||||||
|
));
|
||||||
|
dialog_to_close.close();
|
||||||
|
}
|
||||||
|
Err(error) => controller
|
||||||
|
.status_label
|
||||||
|
.set_text(&format!("캐시를 삭제할 수 없습니다: {error:#}")),
|
||||||
|
});
|
||||||
|
dialog.present();
|
||||||
|
}
|
||||||
|
|
||||||
fn show_location_dialog(self: &Rc<Self>, edit_index: Option<usize>) {
|
fn show_location_dialog(self: &Rc<Self>, edit_index: Option<usize>) {
|
||||||
let existing =
|
let existing =
|
||||||
edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned());
|
edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned());
|
||||||
|
|
@ -1504,11 +1659,7 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt
|
||||||
}
|
}
|
||||||
|
|
||||||
fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
|
fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
|
||||||
match current {
|
current.is_viewable() && candidate.is_viewable()
|
||||||
EntryKind::ZipArchive => candidate.is_viewable(),
|
|
||||||
EntryKind::Image | EntryKind::Video => candidate.is_media(),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
|
fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
|
||||||
|
|
@ -1555,7 +1706,7 @@ fn show_viewer_loading(
|
||||||
window.set_title(Some(file_name));
|
window.set_title(Some(file_name));
|
||||||
let header = gtk::HeaderBar::new();
|
let header = gtk::HeaderBar::new();
|
||||||
header.set_title_widget(Some(>k::Label::new(Some(file_name))));
|
header.set_title_widget(Some(>k::Label::new(Some(file_name))));
|
||||||
window.set_titlebar(Some(&header));
|
set_viewer_header(window, &header);
|
||||||
|
|
||||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||||
content.set_halign(gtk::Align::Center);
|
content.set_halign(gtk::Align::Center);
|
||||||
|
|
@ -1645,11 +1796,13 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn media_navigation_excludes_zip_archives() {
|
fn viewer_navigation_includes_all_viewable_files() {
|
||||||
assert!(navigable_sibling(EntryKind::Image, EntryKind::Video));
|
assert!(navigable_sibling(EntryKind::Image, EntryKind::Video));
|
||||||
assert!(navigable_sibling(EntryKind::Video, EntryKind::Image));
|
assert!(navigable_sibling(EntryKind::Video, EntryKind::Image));
|
||||||
assert!(!navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
|
assert!(navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
|
||||||
|
assert!(navigable_sibling(EntryKind::ZipArchive, EntryKind::Video));
|
||||||
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
|
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
|
||||||
|
assert!(!navigable_sibling(EntryKind::Directory, EntryKind::Image));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
198
src/zip_cache.rs
198
src/zip_cache.rs
|
|
@ -1,4 +1,4 @@
|
||||||
use std::cell::RefCell;
|
use std::cell::{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};
|
||||||
|
|
@ -24,10 +24,18 @@ pub struct ArchiveSource {
|
||||||
|
|
||||||
pub struct ZipCache {
|
pub struct ZipCache {
|
||||||
directory: PathBuf,
|
directory: PathBuf,
|
||||||
quota_bytes: u64,
|
quota_bytes: Cell<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>,
|
||||||
|
|
@ -37,12 +45,18 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,18 +66,24 @@ 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();
|
||||||
if path
|
let incomplete_download = 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Rc::new(Self {
|
let cache = Rc::new(Self {
|
||||||
directory,
|
directory,
|
||||||
quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
|
quota_bytes: Cell::new(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)
|
||||||
|
|
@ -83,7 +103,8 @@ impl ZipCache {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
|
let quota_bytes = self.quota_bytes.get();
|
||||||
|
if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) {
|
||||||
self.download_temporary(source, entry, true, 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);
|
||||||
|
|
@ -127,7 +148,11 @@ impl ZipCache {
|
||||||
|| ".part".to_owned(),
|
|| ".part".to_owned(),
|
||||||
|extension| format!(".part.{extension}"),
|
|extension| format!(".part.{extension}"),
|
||||||
);
|
);
|
||||||
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
|
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()));
|
||||||
|
|
@ -183,6 +208,12 @@ impl ZipCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
|
@ -200,7 +231,7 @@ impl ZipCache {
|
||||||
final_path.display()
|
final_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
cache.enforce_quota(&final_path)?;
|
cache.enforce_quota(Some(&final_path))?;
|
||||||
Ok(cache.lease(final_path, entry.uri, entry.name))
|
Ok(cache.lease(final_path, entry.uri, entry.name))
|
||||||
});
|
});
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
|
|
@ -211,11 +242,35 @@ impl ZipCache {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce_quota(&self, protected: &Path) -> Result<()> {
|
pub fn clear_persistent(&self) -> Result<CacheCleanupReport> {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -224,18 +279,26 @@ 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 <= self.quota_bytes {
|
if total <= limit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if path != protected
|
if protected == Some(path.as_path()) {
|
||||||
&& !self.pinned.borrow().contains_key(&path)
|
continue;
|
||||||
&& fs::remove_file(&path).is_ok()
|
}
|
||||||
{
|
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);
|
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(
|
||||||
|
|
@ -351,7 +414,7 @@ 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: 8,
|
quota_bytes: Cell::new(8),
|
||||||
pinned: RefCell::new(HashMap::new()),
|
pinned: RefCell::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -360,13 +423,104 @@ mod tests {
|
||||||
"file:///first.zip".into(),
|
"file:///first.zip".into(),
|
||||||
"first.zip".into(),
|
"first.zip".into(),
|
||||||
);
|
);
|
||||||
cache.enforce_quota(&second).unwrap();
|
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(&second).unwrap();
|
cache.enforce_quota(Some(&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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue