Release v0.2.9
This commit is contained in:
parent
6c54ec8139
commit
ae1f0c7c4a
8 changed files with 330 additions and 12 deletions
296
src/window.rs
296
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<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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue