Release v0.2.9

This commit is contained in:
burnintuna 2026-07-24 22:13:07 +09:00
parent 6c54ec8139
commit e413fd1a78
8 changed files with 330 additions and 12 deletions

View file

@ -57,6 +57,8 @@ standalone images, and videos.
- Save favorites for local and remote folders and files, and preserve the saved - Save favorites for local and remote folders and files, and preserve the saved
location profile needed to reconnect to remote favorites. location profile needed to reconnect to remote favorites.
- Save progress per ZIP and save the most recently read ZIP for resume support. - Save progress per ZIP and save the most recently read ZIP for resume support.
- Let users update from the About window by downloading the fixed Cafe24 latest
bundle and checksum, verifying SHA-256, and reusing the atomic installer.
## Data Locations ## Data Locations

25
Cargo.lock generated
View file

@ -182,17 +182,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "comicviewer" name = "comicviewer"
version = "0.2.8" version = "0.2.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"filetime", "filetime",
"flate2",
"gtk4", "gtk4",
"image", "image",
"percent-encoding", "percent-encoding",
"rusqlite", "rusqlite",
"serde", "serde",
"sha2", "sha2",
"tar",
"tempfile", "tempfile",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"zip", "zip",
@ -1211,6 +1213,17 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@ -1475,6 +1488,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.54" version = "0.8.54"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "comicviewer" name = "comicviewer"
version = "0.2.8" version = "0.2.9"
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,11 +11,13 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] }
image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] }
filetime = "0.2" filetime = "0.2"
flate2 = "1.0"
percent-encoding = "2.3" percent-encoding = "2.3"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" sha2 = "0.10"
tempfile = "3.20" tempfile = "3.20"
tar = "0.4"
toml = "0.9" toml = "0.9"
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "8", default-features = false, features = ["deflate"] }

View file

@ -26,6 +26,7 @@
</provides> </provides>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.2.9" date="2026-07-24" />
<release version="0.2.8" date="2026-07-24" /> <release version="0.2.8" date="2026-07-24" />
<release version="0.2.7" date="2026-07-24" /> <release version="0.2.7" date="2026-07-24" />
<release version="0.2.6" date="2026-07-23" /> <release version="0.2.6" date="2026-07-23" />

View file

@ -9,7 +9,7 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디
설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다. 설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다.
```sh ```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 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`: FTP와 SFTP 접근
- `gvfs-smb`: SMB 공유 접근 - `gvfs-smb`: SMB 공유 접근
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
- `curl`: About 창에서 최신 업데이트 다운로드
## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트
@ -92,6 +93,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
- `L`: 우측 파일 목록 표시 또는 숨기기 - `L`: 우측 파일 목록 표시 또는 숨기기
- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원
- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치
- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시

View file

@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. 스크립트는 `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`
- `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 "Release v0.2.8" git commit -m "Release v0.2.9"
git tag -a v0.2.8 -m "Comicviewer v0.2.8" git tag -a v0.2.9 -m "Comicviewer v0.2.9"
git push origin main git push origin main
git push origin v0.2.8 git push origin v0.2.9
``` ```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새

View file

@ -39,7 +39,7 @@ install_dependencies() {
fi fi
set -- 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 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}"

View file

@ -1,15 +1,22 @@
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::fs; use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc; use std::rc::Rc;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use flate2::read::GzDecoder;
use gtk::gio; use gtk::gio;
use gtk::gio::prelude::*; use gtk::gio::prelude::*;
use gtk::glib; use gtk::glib;
use gtk::prelude::*; use gtk::prelude::*;
use sha2::{Digest, Sha256};
use tar::Archive;
use crate::browser::{ use crate::browser::{
BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries,
@ -29,6 +36,9 @@ use crate::viewer::{
use crate::zip_cache::ZipCache; use crate::zip_cache::ZipCache;
const MOUSE_BACK_BUTTON: u32 = 8; 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 { pub struct ComicWindow {
controller: Rc<BrowserController>, controller: Rc<BrowserController>,
@ -163,6 +173,7 @@ impl ComicWindow {
database: Rc::new(database), database: Rc::new(database),
zip_cache, zip_cache,
listing_task: RefCell::new(None), listing_task: RefCell::new(None),
update_in_progress: Rc::new(Cell::new(false)),
}); });
controller.rebuild_profiles(); controller.rebuild_profiles();
@ -224,6 +235,7 @@ struct BrowserController {
database: Rc<Database>, database: Rc<Database>,
zip_cache: Rc<ZipCache>, zip_cache: Rc<ZipCache>,
listing_task: RefCell<Option<glib::JoinHandle<()>>>, listing_task: RefCell<Option<glib::JoinHandle<()>>>,
update_in_progress: Rc<Cell<bool>>,
} }
impl BrowserController { impl BrowserController {
@ -1613,18 +1625,72 @@ impl BrowserController {
"사용한 라이브러리\n\ "사용한 라이브러리\n\
GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\
serde · toml · anyhow · chrono · filetime · percent-encoding\n\ serde · toml · anyhow · chrono · filetime · percent-encoding\n\
sha2 · tempfile", sha2 · tempfile · flate2 · tar",
)); ));
libraries.set_xalign(0.0); libraries.set_xalign(0.0);
libraries.set_wrap(true); libraries.set_wrap(true);
let close_button = gtk::Button::with_label("닫기"); 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(); let dialog_to_close = dialog.clone();
close_button.connect_clicked(move |_| dialog_to_close.close()); 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(&name);
content.append(&contact); content.append(&contact);
content.append(&libraries); content.append(&libraries);
content.append(&close_button); content.append(&update_status);
content.append(&actions);
dialog.set_child(Some(&content)); dialog.set_child(Some(&content));
dialog.present(); 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::<String>();
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<String> {
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<String> {
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 { fn location_kind_index(kind: LocationKind) -> u32 {
match kind { match kind {
LocationKind::Local => 0, LocationKind::Local => 0,
@ -2227,6 +2437,84 @@ mod tests {
assert!(!browser_search_match(&nested, "comic")); 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] #[test]
fn favorites_are_rendered_as_browser_paths_and_files() { fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile(); let profile = home_profile();