Release v0.2.9

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

View file

@ -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<BrowserController>,
@ -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<Database>,
zip_cache: Rc<ZipCache>,
listing_task: RefCell<Option<glib::JoinHandle<()>>>,
update_in_progress: Rc<Cell<bool>>,
}
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::<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 {
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();