From ae1f0c7c4a4c4d3e975ea6f898194b408f1c2869 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 22:13:07 +0900 Subject: [PATCH] Release v0.2.9 --- AGENTS.md | 2 + Cargo.lock | 25 +- Cargo.toml | 4 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 4 +- docs/RELEASING.ko.md | 8 +- packaging/install-or-update.sh | 2 +- src/window.rs | 296 +++++++++++++++++- 8 files changed, 330 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2da2b4..a550f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ standalone images, and videos. - Save favorites for local and remote folders and files, and preserve the saved location profile needed to reconnect to remote favorites. - Save progress per ZIP and save the most recently read ZIP for resume support. +- Let users update from the About window by downloading the fixed Cafe24 latest + bundle and checksum, verifying SHA-256, and reusing the atomic installer. ## Data Locations diff --git a/Cargo.lock b/Cargo.lock index 2b7713f..e6712b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,17 +182,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "chrono", "filetime", + "flate2", "gtk4", "image", "percent-encoding", "rusqlite", "serde", "sha2", + "tar", "tempfile", "toml 0.9.12+spec-1.1.0", "zip", @@ -1211,6 +1213,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1475,6 +1488,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "zerocopy" version = "0.8.54" diff --git a/Cargo.toml b/Cargo.toml index 9598d0e..786a6e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.8" +version = "0.2.9" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" @@ -11,11 +11,13 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } filetime = "0.2" +flate2 = "1.0" percent-encoding = "2.3" rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" tempfile = "3.20" +tar = "0.4" toml = "0.9" zip = { version = "8", default-features = false, features = ["deflate"] } diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 682e7d6..93a76d1 100644 --- a/data/io.github.comicviewer.Comicviewer.metainfo.xml +++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml @@ -26,6 +26,7 @@ + diff --git a/docs/INSTALL.ko.md b/docs/INSTALL.ko.md index 16e3805..3f749dc 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -9,7 +9,7 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디 설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다. ```sh -sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ +sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \ gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav ``` @@ -18,6 +18,7 @@ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ - `gvfs`: FTP와 SFTP 접근 - `gvfs-smb`: SMB 공유 접근 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 +- `curl`: About 창에서 최신 업데이트 다운로드 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 @@ -92,6 +93,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - `L`: 우측 파일 목록 표시 또는 숨기기 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 +- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index b2a446f..d56de51 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.8-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.9-x86_64.AppImage.sha256` - `dist/Comicviewer-latest-x86_64.tar.gz` - `dist/Comicviewer-latest-x86_64.tar.gz.sha256` @@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 ```sh git add Cargo.toml Cargo.lock data packaging README.md docs -git commit -m "Release v0.2.8" -git tag -a v0.2.8 -m "Comicviewer v0.2.8" +git commit -m "Release v0.2.9" +git tag -a v0.2.9 -m "Comicviewer v0.2.9" git push origin main -git push origin v0.2.8 +git push origin v0.2.9 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh index df1d294..fbc1893 100755 --- a/packaging/install-or-update.sh +++ b/packaging/install-or-update.sh @@ -39,7 +39,7 @@ install_dependencies() { fi set -- - for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \ + for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl gst-plugins-base \ gst-plugins-good gst-plugins-bad gst-libav; do if ! pacman -Q "${package}" >/dev/null 2>&1; then set -- "$@" "${package}" diff --git a/src/window.rs b/src/window.rs index 534741c..e9fdfd3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,15 +1,22 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; use std::rc::Rc; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Local}; +use flate2::read::GzDecoder; use gtk::gio; use gtk::gio::prelude::*; use gtk::glib; use gtk::prelude::*; +use sha2::{Digest, Sha256}; +use tar::Archive; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, @@ -29,6 +36,9 @@ use crate::viewer::{ use crate::zip_cache::ZipCache; const MOUSE_BACK_BUTTON: u32 = 8; +const UPDATE_BUNDLE_URL: &str = + "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz"; +const UPDATE_CHECKSUM_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256"; pub struct ComicWindow { controller: Rc, @@ -163,6 +173,7 @@ impl ComicWindow { database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), + update_in_progress: Rc::new(Cell::new(false)), }); controller.rebuild_profiles(); @@ -224,6 +235,7 @@ struct BrowserController { database: Rc, zip_cache: Rc, listing_task: RefCell>>, + update_in_progress: Rc>, } impl BrowserController { @@ -1613,18 +1625,72 @@ impl BrowserController { "사용한 라이브러리\n\ GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ serde · toml · anyhow · chrono · filetime · percent-encoding\n\ - sha2 · tempfile", + sha2 · tempfile · flate2 · tar", )); libraries.set_xalign(0.0); libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); - close_button.set_halign(gtk::Align::End); + let update_button = gtk::Button::with_label("업데이트"); + let update_status = gtk::Label::new(None); + update_status.set_wrap(true); + update_status.set_xalign(0.0); + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + actions.set_halign(gtk::Align::End); + actions.append(&update_button); + actions.append(&close_button); + if self.update_in_progress.get() { + update_button.set_sensitive(false); + update_status.set_text("다른 업데이트 작업이 진행 중입니다."); + } let dialog_to_close = dialog.clone(); close_button.connect_clicked(move |_| dialog_to_close.close()); + let status = update_status.clone(); + let update_in_progress = Rc::clone(&self.update_in_progress); + update_button.connect_clicked(move |button| { + if update_in_progress.replace(true) { + return; + } + button.set_sensitive(false); + status.set_text("업데이트를 다운로드하고 검증하는 중..."); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = install_latest_update().map_err(|error| format!("{error:#}")); + let _ = sender.send(result); + }); + let button = button.clone(); + let status = status.clone(); + let update_in_progress = Rc::clone(&update_in_progress); + glib::timeout_add_local(Duration::from_millis(100), move || { + match receiver.try_recv() { + Ok(Ok(())) => { + update_in_progress.set(false); + status.set_text( + "업데이트를 완료했습니다. 앱을 종료한 뒤 ~/.local/bin/comicviewer로 다시 실행해 주세요.", + ); + button.set_label("업데이트 완료"); + glib::ControlFlow::Break + } + Ok(Err(error)) => { + update_in_progress.set(false); + status.set_text(&format!("업데이트 실패: {error}")); + button.set_sensitive(true); + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => { + update_in_progress.set(false); + status.set_text("업데이트 작업이 예기치 않게 종료되었습니다."); + button.set_sensitive(true); + glib::ControlFlow::Break + } + } + }); + }); content.append(&name); content.append(&contact); content.append(&libraries); - content.append(&close_button); + content.append(&update_status); + content.append(&actions); dialog.set_child(Some(&content)); dialog.present(); } @@ -1855,6 +1921,150 @@ impl BrowserController { } } +fn install_latest_update() -> Result<()> { + let directory = tempfile::tempdir().context("업데이트 임시 폴더를 만들 수 없습니다")?; + let archive = directory.path().join("Comicviewer-latest-x86_64.tar.gz"); + let checksum = directory + .path() + .join("Comicviewer-latest-x86_64.tar.gz.sha256"); + download_update_file(UPDATE_BUNDLE_URL, &archive)?; + download_update_file(UPDATE_CHECKSUM_URL, &checksum)?; + + let checksum_text = + fs::read_to_string(&checksum).context("업데이트 체크섬을 읽을 수 없습니다")?; + let expected = parse_update_checksum(&checksum_text)?; + let actual = sha256_file(&archive)?; + if actual != expected { + bail!("다운로드한 업데이트 체크섬이 일치하지 않습니다"); + } + + let extraction = directory.path().join("extracted"); + fs::create_dir(&extraction).context("업데이트 압축 해제 폴더를 만들 수 없습니다")?; + extract_update_archive(&archive, &extraction)?; + let installer = extraction + .join("Comicviewer-latest-x86_64") + .join("install-or-update.sh"); + if !installer.is_file() { + bail!("업데이트 번들에 설치 스크립트가 없습니다"); + } + run_update_command( + Command::new("sh") + .arg(&installer) + .env("COMICVIEWER_SKIP_DEPENDENCIES", "1"), + "업데이트 설치에 실패했습니다", + ) +} + +fn download_update_file(url: &str, destination: &Path) -> Result<()> { + let url = format!("{url}?from={}", env!("CARGO_PKG_VERSION")); + run_update_command( + Command::new("curl") + .arg("--fail") + .arg("--silent") + .arg("--show-error") + .arg("--location") + .arg("--proto") + .arg("=https") + .arg("--proto-redir") + .arg("=https") + .arg("--connect-timeout") + .arg("15") + .arg("--max-time") + .arg("300") + .arg("--max-filesize") + .arg((512_u64 * 1024 * 1024).to_string()) + .arg("--retry") + .arg("3") + .arg("--output") + .arg(destination) + .arg(url), + "업데이트를 다운로드할 수 없습니다. curl 설치와 네트워크 연결을 확인해 주세요", + ) +} + +fn run_update_command(command: &mut Command, context: &str) -> Result<()> { + let output = command.output().with_context(|| context.to_owned())?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr) + .trim() + .chars() + .take(600) + .collect::(); + if stderr.is_empty() { + bail!("{context}"); + } + bail!("{context}: {stderr}") +} + +fn extract_update_archive(archive_path: &Path, destination: &Path) -> Result<()> { + const MAX_ENTRIES: usize = 32; + const MAX_EXTRACTED_BYTES: u64 = 512 * 1024 * 1024; + + let file = fs::File::open(archive_path).context("업데이트 압축 파일을 열 수 없습니다")?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let mut count = 0_usize; + let mut extracted_bytes = 0_u64; + for entry in archive + .entries() + .context("업데이트 압축 목록을 읽을 수 없습니다")? + { + let mut entry = entry.context("업데이트 압축 항목을 읽을 수 없습니다")?; + count += 1; + if count > MAX_ENTRIES { + bail!("업데이트 압축 항목 수가 안전 제한을 초과합니다"); + } + let entry_type = entry.header().entry_type(); + if !entry_type.is_file() && !entry_type.is_dir() { + bail!("업데이트 압축에 허용되지 않은 파일 종류가 있습니다"); + } + let path = entry + .path() + .context("업데이트 압축 경로가 올바르지 않습니다")?; + if !path.starts_with("Comicviewer-latest-x86_64") { + bail!("업데이트 압축 경로가 올바르지 않습니다"); + } + extracted_bytes = extracted_bytes.saturating_add(entry.header().size().unwrap_or(u64::MAX)); + if extracted_bytes > MAX_EXTRACTED_BYTES { + bail!("업데이트 압축 크기가 안전 제한을 초과합니다"); + } + if !entry + .unpack_in(destination) + .context("업데이트 압축을 풀 수 없습니다")? + { + bail!("업데이트 압축 경로가 임시 폴더를 벗어납니다"); + } + } + Ok(()) +} + +fn parse_update_checksum(contents: &str) -> Result { + let checksum = contents + .split_whitespace() + .next() + .filter(|checksum| { + checksum.len() == 64 && checksum.chars().all(|value| value.is_ascii_hexdigit()) + }) + .context("업데이트 체크섬 파일이 올바르지 않습니다")?; + Ok(checksum.to_ascii_lowercase()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).context("다운로드한 업데이트를 읽을 수 없습니다")?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(format!("{:x}", digest.finalize())) +} + fn location_kind_index(kind: LocationKind) -> u32 { match kind { LocationKind::Local => 0, @@ -2227,6 +2437,84 @@ mod tests { assert!(!browser_search_match(&nested, "comic")); } + #[test] + fn update_checksum_parser_validates_sha256_text() { + let checksum = + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 bundle.tar.gz\n"; + assert_eq!( + parse_update_checksum(checksum).unwrap(), + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + ); + assert!(parse_update_checksum("not-a-checksum").is_err()); + } + + #[test] + fn update_sha256_matches_known_content() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("content"); + fs::write(&file, b"abc").unwrap(); + assert_eq!( + sha256_file(&file).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn update_archive_extracts_regular_files_and_rejects_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let valid_archive = directory.path().join("valid.tar.gz"); + { + let file = fs::File::create(&valid_archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let content = b"#!/bin/sh\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive + .append_data( + &mut header, + "Comicviewer-latest-x86_64/install-or-update.sh", + &content[..], + ) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + } + let extracted = directory.path().join("valid"); + fs::create_dir(&extracted).unwrap(); + extract_update_archive(&valid_archive, &extracted).unwrap(); + assert!( + extracted + .join("Comicviewer-latest-x86_64/install-or-update.sh") + .is_file() + ); + + let link_archive = directory.path().join("link.tar.gz"); + { + let file = fs::File::create(&link_archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_link_name("../../escape").unwrap(); + header.set_cksum(); + archive + .append_data( + &mut header, + "Comicviewer-latest-x86_64/link", + std::io::empty(), + ) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + } + let rejected = directory.path().join("rejected"); + fs::create_dir(&rejected).unwrap(); + assert!(extract_update_archive(&link_archive, &rejected).is_err()); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile();