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, }; use crate::config::{ AppConfig, FavoriteEntry, LocationKind, LocationProfile, RecentFileEntry, ViewerMode, }; use crate::database::Database; use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, TwoPageDirectionControl, ViewModeControl, ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, }; use crate::zip_cache::ZipCache; const MOUSE_BACK_BUTTON: u32 = 8; const RECENT_FILE_LIMIT: usize = 100; 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, } impl ComicWindow { pub fn new( application: >k::Application, config: AppConfig, config_path: PathBuf, database: Database, zip_cache: Rc, ) -> Result { let initial_profile = home_profile(); let root_uri: String = root_file(&initial_profile)?.uri().into(); let window = gtk::ApplicationWindow::builder() .application(application) .title("Comicviewer") .default_width(1100) .default_height(720) .build(); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some("Comicviewer")))); let about_button = gtk::Button::with_label("정보"); header.pack_end(&about_button); let clear_cache_button = gtk::Button::with_label("캐시 비우기"); header.pack_end(&clear_cache_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 8); root.set_margin_top(12); root.set_margin_bottom(12); root.set_margin_start(12); root.set_margin_end(12); let locations = gtk::Box::new(gtk::Orientation::Horizontal, 6); let location_dropdown = gtk::DropDown::from_strings(&["내 홈"]); location_dropdown.set_hexpand(true); let add_location_button = gtk::Button::with_label("위치 추가"); let edit_location_button = gtk::Button::with_label("수정"); edit_location_button.set_sensitive(false); let delete_location_button = gtk::Button::with_label("삭제"); delete_location_button.set_sensitive(false); let clear_recent_button = gtk::Button::with_label("최근 기록 비우기"); clear_recent_button.set_visible(false); locations.append(>k::Label::new(Some("위치"))); locations.append(&location_dropdown); locations.append(&add_location_button); locations.append(&edit_location_button); locations.append(&delete_location_button); locations.append(&clear_recent_button); let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") .tooltip_text("상위 폴더") .build(); let path_entry = gtk::Entry::builder().hexpand(true).build(); let open_button = gtk::Button::with_label("이동"); let refresh_button = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text("새로고침") .build(); navigation.append(&up_button); navigation.append(&path_entry); navigation.append(&open_button); navigation.append(&refresh_button); let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); let search_entry = gtk::SearchEntry::builder() .placeholder_text("현재 폴더에서 검색") .hexpand(true) .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let direction_button = gtk::ToggleButton::with_label("오름차순"); let case_insensitive_check = gtk::CheckButton::with_label("대소문자 구분 안 함"); case_insensitive_check.set_active(config.case_insensitive_sort); let fast_loading_switch = gtk::Switch::builder() .active(config.fast_image_loading) .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") .build(); controls.append(&search_entry); controls.append(&sort_dropdown); controls.append(&direction_button); controls.append(&case_insensitive_check); controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); let list_selection = gtk::SingleSelection::new(None::); let list_view = gtk::ListView::new(Some(list_selection.clone()), None::); list_view.set_single_click_activate(false); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .child(&list_view) .build(); let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); root.append(&navigation); root.append(&controls); root.append(&scrolled); root.append(&status_label); window.set_child(Some(&root)); let controller = Rc::new(BrowserController { window, about_button, clear_cache_button, location_dropdown, edit_location_button, delete_location_button, clear_recent_button: clear_recent_button.clone(), path_entry, search_entry, sort_dropdown, direction_button, list_view, list_selection, status_label, state: RefCell::new(BrowserState { profile_index: 0, favorite_profile: None, mode: BrowserMode::Directory, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), visible_entries: Vec::new(), sort_field: SortField::Name, sort_direction: SortDirection::Ascending, generation: 0, typeahead_query: String::new(), typeahead_last_input: None, }), config: RefCell::new(config), config_path, database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), update_in_progress: Rc::new(Cell::new(false)), }); controller.rebuild_profiles(); controller.connect_signals( open_button, refresh_button, up_button, add_location_button, case_insensitive_check, fast_loading_switch, ); install_file_drop_target(&controller.window, controller.file_drop_control()); controller.refresh(); Ok(Self { controller }) } pub fn present(&self) { self.controller.window.present(); } pub fn open_local_items(&self, files: &[gio::File]) { self.present(); self.controller .open_dropped_files(files.to_vec(), self.controller.window.clone().upcast()); } } struct BrowserState { profile_index: usize, favorite_profile: Option, mode: BrowserMode, root_uri: String, current_uri: String, entries: Vec, visible_entries: Vec, sort_field: SortField, sort_direction: SortDirection, generation: u64, typeahead_query: String, typeahead_last_input: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BrowserMode { Directory, Favorites, Recent, } struct BrowserController { window: gtk::ApplicationWindow, about_button: gtk::Button, clear_cache_button: gtk::Button, location_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, clear_recent_button: gtk::Button, path_entry: gtk::Entry, search_entry: gtk::SearchEntry, sort_dropdown: gtk::DropDown, direction_button: gtk::ToggleButton, list_view: gtk::ListView, list_selection: gtk::SingleSelection, status_label: gtk::Label, state: RefCell, config: RefCell, config_path: PathBuf, database: Rc, zip_cache: Rc, listing_task: RefCell>>, update_in_progress: Rc>, } impl BrowserController { fn connect_signals( self: &Rc, open_button: gtk::Button, refresh_button: gtk::Button, up_button: gtk::Button, add_location_button: gtk::Button, case_insensitive_check: gtk::CheckButton, fast_loading_switch: gtk::Switch, ) { let controller = Rc::clone(self); open_button.connect_clicked(move |_| controller.open_entered_path()); let controller = Rc::clone(self); self.path_entry .connect_activate(move |_| controller.open_entered_path()); let controller = Rc::clone(self); refresh_button.connect_clicked(move |_| controller.refresh()); let controller = Rc::clone(self); up_button.connect_clicked(move |_| controller.go_up()); let controller = Rc::clone(self); add_location_button.connect_clicked(move |_| controller.show_location_dialog(None)); let controller = Rc::clone(self); self.about_button .connect_clicked(move |_| controller.show_about_dialog()); let controller = Rc::clone(self); self.edit_location_button.connect_clicked(move |_| { if let Some(index) = controller.selected_config_index() { controller.show_location_dialog(Some(index)); } }); let controller = Rc::clone(self); self.delete_location_button.connect_clicked(move |_| { if let Some(index) = controller.selected_config_index() { controller.show_delete_location_dialog(index); } }); let controller = Rc::clone(self); self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let location_factory = gtk::SignalListItemFactory::new(); let controller = Rc::downgrade(self); location_factory.connect_setup(move |_, item| { let Some(item) = item.downcast_ref::() else { return; }; let label = gtk::Label::builder().xalign(0.0).build(); let click = gtk::GestureClick::builder().button(1).build(); item.set_child(Some(&label)); let item = item.downgrade(); let controller = controller.clone(); click.connect_released(move |_, _, _, _| { let Some(item) = item.upgrade() else { return; }; let Some(controller) = controller.upgrade() else { return; }; match special_browser_mode(item.position(), controller.profiles().len()) { Some(BrowserMode::Favorites) => controller.show_favorites(), Some(BrowserMode::Recent) => controller.show_recent(), _ => {} } }); label.add_controller(click); }); location_factory.connect_bind(|_, item| { let Some(item) = item.downcast_ref::() else { return; }; let Some(label) = item.child().and_downcast::() else { return; }; let Some(value) = item.item().and_downcast::() else { return; }; label.set_text(value.string().as_str()); }); self.location_dropdown .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); self.sort_dropdown .connect_selected_notify(move |_| controller.change_sort_field()); let controller = Rc::clone(self); self.direction_button .connect_toggled(move |_| controller.change_sort_direction()); let controller = Rc::clone(self); case_insensitive_check.connect_toggled(move |check| { controller.set_case_insensitive_sort(check); }); let factory = gtk::SignalListItemFactory::new(); let controller = Rc::downgrade(self); factory.connect_setup(move |_, item| { let Some(item) = item.downcast_ref::() else { return; }; let content = entry_content(); item.set_child(Some(&content)); let context_click = gtk::GestureClick::builder().button(3).build(); let item = item.downgrade(); let controller = controller.clone(); let menu_parent = content.clone(); context_click.connect_pressed(move |_, _, _, _| { let Some(item) = item.upgrade() else { return; }; let Some(controller) = controller.upgrade() else { return; }; controller.show_context_menu(item.position(), &menu_parent); }); content.add_controller(context_click); }); let controller = Rc::downgrade(self); factory.connect_bind(move |_, item| { let Some(item) = item.downcast_ref::() else { return; }; let Some(content) = item.child().and_downcast::() else { return; }; let Some(controller) = controller.upgrade() else { return; }; let state = controller.state.borrow(); let Some(entry) = state.visible_entries.get(item.position() as usize) else { return; }; bind_entry_content(&content, entry); }); self.list_view.set_factory(Some(&factory)); let controller = Rc::clone(self); self.list_view .connect_activate(move |_, position| controller.activate_row(position)); let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller.connect_key_pressed(move |_, key, _, modifiers| { if key == gtk::gdk::Key::BackSpace && !controller.typeahead_is_active() { controller.go_up(); glib::Propagation::Stop } else { controller.typeahead(key, modifiers) } }); self.list_view.add_controller(key_controller); let mouse_back = gtk::GestureClick::builder() .button(MOUSE_BACK_BUTTON) .propagation_phase(gtk::PropagationPhase::Capture) .build(); let controller = Rc::clone(self); mouse_back.connect_pressed(move |gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); controller.go_up(); }); self.window.add_controller(mouse_back); let controller = Rc::clone(self); fast_loading_switch.connect_active_notify(move |switch| { controller.set_fast_image_loading(switch); }); let controller = Rc::clone(self); self.clear_cache_button .connect_clicked(move |_| controller.show_clear_cache_dialog()); let controller = Rc::clone(self); self.clear_recent_button .connect_clicked(move |_| controller.show_clear_recent_dialog()); } fn profiles(&self) -> Vec { let mut profiles = vec![home_profile()]; profiles.extend(self.config.borrow().locations.clone()); profiles } fn current_profile(&self) -> LocationProfile { let state = self.state.borrow(); state.favorite_profile.clone().unwrap_or_else(|| { self.profiles() .get(state.profile_index) .cloned() .unwrap_or_else(home_profile) }) } fn rebuild_profiles(&self) { let profiles = self.profiles(); let mut names: Vec<_> = profiles .iter() .map(|profile| profile.name.clone()) .collect(); names.push("즐겨찾기".into()); names.push("최근 열어본 파일".into()); let names: Vec<_> = names.iter().map(String::as_str).collect(); self.location_dropdown .set_model(Some(>k::StringList::new(&names))); } fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let profiles = self.profiles(); match special_browser_mode(self.location_dropdown.selected(), profiles.len()) { Some(BrowserMode::Favorites) => { self.show_favorites(); return; } Some(BrowserMode::Recent) => { self.show_recent(); return; } _ => {} } let editable = profile_index > 0 && profile_index < profiles.len(); self.edit_location_button.set_sensitive(editable); self.delete_location_button.set_sensitive(editable); let Some(profile) = profiles.get(profile_index) else { return; }; match root_file(profile) { Ok(root) => { let root_uri: String = root.uri().into(); let mut state = self.state.borrow_mut(); state.profile_index = profile_index; state.favorite_profile = None; state.mode = BrowserMode::Directory; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); self.update_mode_controls(BrowserMode::Directory); self.path_entry.set_editable(true); self.search_entry.set_text(""); self.refresh(); } Err(error) => self.status_label.set_text(&format!("{error:#}")), } } fn show_favorites(&self) { self.cancel_listing(); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_text("즐겨찾기"); self.path_entry.set_editable(false); let entries = self .config .borrow() .favorites .iter() .map(favorite_browser_entry) .collect(); let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; state.mode = BrowserMode::Favorites; state.favorite_profile = None; state.entries = entries; drop(state); self.update_mode_controls(BrowserMode::Favorites); self.search_entry.set_text(""); self.render(); } fn show_recent(&self) { self.cancel_listing(); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_text("최근 열어본 파일"); self.path_entry.set_editable(false); let entries = self .config .borrow() .recent_files .iter() .map(recent_file_browser_entry) .collect(); let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; state.mode = BrowserMode::Recent; state.favorite_profile = None; state.entries = entries; drop(state); self.update_mode_controls(BrowserMode::Recent); self.search_entry.set_text(""); self.render(); } fn update_mode_controls(&self, mode: BrowserMode) { let recent = mode == BrowserMode::Recent; self.clear_recent_button.set_visible(recent); self.sort_dropdown.set_sensitive(!recent); self.direction_button.set_sensitive(!recent); } fn open_favorite(self: &Rc, favorite: FavoriteEntry) { let Ok(root) = root_file(&favorite.profile) else { self.status_label .set_text("저장된 파일의 위치 정보가 올바르지 않습니다."); return; }; let favorite_file = gio::File::for_uri(&favorite.uri); if !is_within_root(&favorite_file, &root) { self.status_label .set_text("저장된 파일이 위치의 루트 밖을 가리킵니다."); return; } if favorite.is_directory { self.location_dropdown .set_selected(gtk::INVALID_LIST_POSITION); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(favorite.profile.clone()); state.mode = BrowserMode::Directory; state.root_uri = root.uri().into(); state.current_uri = favorite.uri; drop(state); self.update_mode_controls(BrowserMode::Directory); self.search_entry.set_text(""); self.refresh(); return; } self.cancel_listing(); self.status_label.set_text("저장된 파일에 연결하는 중..."); let operation = mount_operation(&favorite.profile, &self.window); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let file = gio::File::for_uri(&favorite.uri); 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 { return; }; if let Err(error) = result { controller.status_label.set_text(&format!("{error:#}")); return; } let kind = classify_file(Path::new(&favorite.name)); if !kind.is_viewable() { controller .status_label .set_text("이 파일 형식은 열 수 없습니다."); return; } let entry = folder_entries .as_ref() .and_then(|entries| entries.iter().find(|entry| entry.uri == favorite.uri)) .cloned() .unwrap_or_else(|| BrowserEntry { uri: favorite.uri.clone(), name: favorite.name.clone(), list_name: None, kind, size: None, modified: None, created: None, etag: None, }); 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); }); self.listing_task.replace(Some(task)); } fn open_entered_path(self: &Rc) { if self.state.borrow().mode != BrowserMode::Directory { return; } let profile = self.current_profile(); let entered = self.path_entry.text(); let file = file_from_user_input(profile.kind, entered.as_str()); let root = gio::File::for_uri(&self.state.borrow().root_uri); if !is_within_root(&file, &root) { self.status_label .set_text("저장된 위치의 루트 아래 경로만 열 수 있습니다."); return; } self.state.borrow_mut().current_uri = file.uri().into(); self.refresh(); } fn go_up(self: &Rc) { if self.state.borrow().mode != BrowserMode::Directory { return; } let state = self.state.borrow(); let current = gio::File::for_uri(&state.current_uri); let root = gio::File::for_uri(&state.root_uri); drop(state); if let Some(parent) = parent_within_root(¤t, &root) { self.state.borrow_mut().current_uri = parent.uri().into(); self.refresh(); } } fn refresh(self: &Rc) { let mode = self.state.borrow().mode; match mode { BrowserMode::Favorites => { self.show_favorites(); return; } BrowserMode::Recent => { self.show_recent(); return; } BrowserMode::Directory => {} } self.cancel_listing(); let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; state.entries.clear(); state.visible_entries.clear(); ( state .favorite_profile .clone() .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), state.current_uri.clone(), state.generation, ) }; self.list_selection.set_model(None::<&gio::ListModel>); self.path_entry.set_text(&display_uri(&uri)); self.status_label .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result = enumerate_directory(&profile, &uri, &operation, move |count| { let Some(controller) = progress_controller.upgrade() else { return; }; let state = controller.state.borrow(); if state.generation == generation && state.mode == BrowserMode::Directory { controller.status_label.set_text(&format!( "폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)" )); } }) .await; let Some(controller) = controller.upgrade() else { return; }; let state = controller.state.borrow(); let stale = state.generation != generation || state.mode != BrowserMode::Directory; drop(state); if stale { return; } match result { Ok(entries) => { controller.state.borrow_mut().entries = entries; controller.render(); } 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) { let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); let mut visible_entries: Vec<_> = state .entries .iter() .filter(|entry| browser_search_match(entry, query.as_str())) .cloned() .collect(); if state.mode != BrowserMode::Recent { sort_entries( &mut visible_entries, state.sort_field, state.sort_direction, case_insensitive, ); } let count = visible_entries.len(); let names = visible_entries .iter() .map(BrowserEntry::display_name) .collect::>(); let model = gtk::StringList::new(&names); state.visible_entries = visible_entries; drop(state); self.list_selection.set_model(Some(&model)); self.status_label.set_text(&format!("{count}개 항목")); } fn typeahead( &self, key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, ) -> glib::Propagation { let blocked = gtk::gdk::ModifierType::CONTROL_MASK | gtk::gdk::ModifierType::ALT_MASK | gtk::gdk::ModifierType::SUPER_MASK; if modifiers.intersects(blocked) { return glib::Propagation::Proceed; } if key == gtk::gdk::Key::Escape { let mut state = self.state.borrow_mut(); state.typeahead_query.clear(); state.typeahead_last_input = None; return glib::Propagation::Stop; } if key == gtk::gdk::Key::BackSpace { let mut state = self.state.borrow_mut(); if state.typeahead_query.pop().is_none() { return glib::Propagation::Proceed; } state.typeahead_last_input = Some(Instant::now()); return glib::Propagation::Stop; } let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else { return glib::Propagation::Proceed; }; let selected = (self.list_selection.selected() != gtk::INVALID_LIST_POSITION) .then(|| self.list_selection.selected() as usize); let now = Instant::now(); let mut state = self.state.borrow_mut(); if state .typeahead_last_input .is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200)) { state.typeahead_query.clear(); } let input = character.to_lowercase().collect::(); let repeated_single = state.typeahead_query.chars().count() == 1 && state.typeahead_query == input; if !repeated_single { state.typeahead_query.push_str(&input); } state.typeahead_last_input = Some(now); let query = state.typeahead_query.clone(); let start = if repeated_single { selected.map_or(0, |index| index + 1) } else { 0 }; let found = find_prefix_index(&state.visible_entries, &query, start); let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); drop(state); if let Some(index) = found { self.list_view.scroll_to( index as u32, gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, None, ); self.status_label.set_text(&format!( "'{query}'로 이동: {}", found_name.unwrap_or_default() )); } else { self.status_label .set_text(&format!("'{query}'로 시작하는 항목이 없습니다.")); } glib::Propagation::Stop } fn typeahead_is_active(&self) -> bool { let state = self.state.borrow(); !state.typeahead_query.is_empty() && state.typeahead_last_input.is_some_and(|last| { Instant::now().duration_since(last) <= Duration::from_millis(1200) }) } fn change_sort_field(&self) { self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() { 1 => SortField::Modified, 2 => SortField::Created, _ => SortField::Name, }; self.render(); } fn change_sort_direction(&self) { let direction = if self.direction_button.is_active() { self.direction_button.set_label("내림차순"); SortDirection::Descending } else { self.direction_button.set_label("오름차순"); SortDirection::Ascending }; self.state.borrow_mut().sort_direction = direction; self.render(); } fn activate_row(self: &Rc, index: u32) { let Some(entry) = self .state .borrow() .visible_entries .get(index as usize) .cloned() else { return; }; let mode = self.state.borrow().mode; match mode { BrowserMode::Favorites => { let favorite = self .config .borrow() .favorites .iter() .find(|favorite| favorite.uri == entry.uri) .cloned(); if let Some(favorite) = favorite { self.open_favorite(favorite); } return; } BrowserMode::Recent => { let recent = self .config .borrow() .recent_files .iter() .find(|recent| recent.uri == entry.uri) .cloned(); if let Some(recent) = recent { self.open_favorite(FavoriteEntry { name: recent.name, uri: recent.uri, is_directory: false, profile: recent.profile, }); } return; } BrowserMode::Directory => {} } match entry.kind { EntryKind::Directory => { self.state.borrow_mut().current_uri = entry.uri; self.search_entry.set_text(""); self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { let current_kind = entry.kind; let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { let state = self.state.borrow(); sorted_navigable_entries( &state.entries, current_kind, state.sort_field, state.sort_direction, case_insensitive, ) }; let siblings = Rc::new(siblings); if siblings.iter().any(|item| item.uri == entry.uri) { self.open_viewable(entry, siblings, self.current_profile(), None); } } EntryKind::OtherFile => {} } } fn open_viewable( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { match entry.kind { EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window), EntryKind::Image | EntryKind::Video => { self.open_media(entry, siblings, profile, reuse_window) } _ => {} } } fn sibling_navigator( self: &Rc, entry: &BrowserEntry, siblings: Rc>, profile: LocationProfile, ) -> SiblingNavigator { let recursive = siblings.iter().any(|entry| entry.list_name.is_some()); let current_uri = entry.uri.clone(); let current_kind = entry.kind; 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( move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| { let Some(controller) = controller.upgrade() else { return false; }; let siblings = controller.navigation_siblings( &navigate_current_uri, current_kind, &navigate_siblings, recursive, ); let Some(current) = siblings .iter() .position(|entry| entry.uri == navigate_current_uri) else { return false; }; let Some(next) = target.resolve(current, siblings.len()) else { return false; }; controller.open_viewable( siblings[next].clone(), Rc::clone(&siblings), navigate_profile.clone(), Some(reuse_window), ); true }, ); 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, recursive) .iter() .map(|entry| SiblingFile { uri: entry.uri.clone(), name: entry.display_name().to_owned(), 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, recursive, ); 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(navigate, files, actions, recursive) } fn navigation_siblings( &self, current_uri: &str, current_kind: EntryKind, fallback: &Rc>, fixed: bool, ) -> Rc> { if fixed { return Rc::clone(fallback); } let case_insensitive = self.config.borrow().case_insensitive_sort; let state = self.state.borrow(); let entries = sorted_navigable_entries( &state.entries, current_kind, state.sort_field, state.sort_direction, case_insensitive, ); let mode = state.mode; drop(state); if mode == BrowserMode::Directory && entries.iter().any(|entry| entry.uri == current_uri) { Rc::new(entries) } else { Rc::clone(fallback) } } fn open_archive( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { let recent_entry = entry.clone(); let recent_profile = profile.clone(); self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); let progress_label = reuse_window.as_ref().map_or_else( || self.status_label.clone(), |window| show_viewer_loading(window, &entry.name, "ZIP 파일을 준비하는 중..."), ); let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); let view_mode_control = self.view_mode_control(); self.zip_cache.prepare( entry, move |current, total| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%")); }, move |result| { if reuse_window .as_ref() .is_some_and(|window| !window.is_visible()) { return; } match result { Ok(source) => { controller.record_recent_file(&recent_entry, &recent_profile); controller.status_label.set_text("다운로드 완료"); result_label.set_text("ZIP 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; let direction_control = controller.two_page_direction_control(); ViewerWindow::open( &application, source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, controller.config.borrow().case_insensitive_sort, view_mode_control, FileViewerControls { sibling_navigator: Some(navigator), favorite_control, download_control, file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control: None, two_page_direction_control: Some(direction_control), }, ); } Err(error) => { let message = format!("{error:#}"); controller.status_label.set_text(&message); result_label.set_text(&message); } } }, ); } fn open_media( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { let recent_entry = entry.clone(); let recent_profile = profile.clone(); let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); let progress_label = reuse_window.as_ref().map_or_else( || self.status_label.clone(), |window| show_viewer_loading(window, &entry.name, "미디어 파일을 준비하는 중..."), ); let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); let view_mode_control = (kind == EntryKind::Image).then(|| self.view_mode_control()); self.zip_cache.prepare_media( entry, move |current, total| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("미디어 다운로드 중... {percent}%")); }, move |result| { if reuse_window .as_ref() .is_some_and(|window| !window.is_visible()) { return; } match result { Ok(source) => { controller.record_recent_file(&recent_entry, &recent_profile); controller.status_label.set_text("다운로드 완료"); result_label.set_text("미디어 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; MediaViewerWindow::open( &application, source, kind, FileViewerControls { sibling_navigator: Some(navigator), favorite_control, download_control, file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control, two_page_direction_control: None, }, ); } Err(error) => { let message = format!("{error:#}"); controller.status_label.set_text(&message); result_label.set_text(&message); } } }, ); } fn show_context_menu( self: &Rc, position: u32, parent: &impl glib::object::IsA, ) { let Some(entry) = self .state .borrow() .visible_entries .get(position as usize) .cloned() else { return; }; let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); let can_download = entry.kind != EntryKind::Directory; if !can_favorite && !can_download { return; } self.list_selection.set_selected(position); let popover = gtk::Popover::new(); popover.set_parent(parent); let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); actions.set_margin_top(6); actions.set_margin_bottom(6); actions.set_margin_start(6); actions.set_margin_end(6); if entry.kind == EntryKind::Directory { let open_folder_button = gtk::Button::with_label("폴더 열기"); actions.append(&open_folder_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); let folder_entry = entry.clone(); open_folder_button.connect_clicked(move |_| { popover_to_close.popdown(); controller.open_folder_recursive(folder_entry.clone()); }); } if can_favorite { let is_favorite = self.is_favorite(&entry.uri); let favorite_button = gtk::Button::with_label(if is_favorite { "즐겨찾기 제거" } else { "즐겨찾기 추가" }); actions.append(&favorite_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); let favorite_entry = entry.clone(); favorite_button.connect_clicked(move |_| { popover_to_close.popdown(); let profile = controller.profile_for_entry(&favorite_entry); controller.toggle_favorite(&favorite_entry, &profile); }); } if can_download { let download_button = gtk::Button::with_label("파일 다운로드..."); actions.append(&download_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); download_button.connect_clicked(move |_| { popover_to_close.popdown(); controller.choose_download_destination( entry.clone(), controller.window.clone().upcast(), controller.status_label.clone(), ); }); } popover.set_child(Some(&actions)); popover.popup(); } fn is_favorite(&self, uri: &str) -> bool { self.config .borrow() .favorites .iter() .any(|favorite| favorite.uri == uri) } fn profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile { match self.state.borrow().mode { BrowserMode::Favorites => { if let Some(profile) = self .config .borrow() .favorites .iter() .find(|favorite| favorite.uri == entry.uri) .map(|favorite| favorite.profile.clone()) { return profile; } } BrowserMode::Recent => { if let Some(profile) = self .config .borrow() .recent_files .iter() .find(|recent| recent.uri == entry.uri) .map(|recent| recent.profile.clone()) { return profile; } } BrowserMode::Directory => {} } self.current_profile() } fn open_folder_recursive(self: &Rc, entry: BrowserEntry) { let profile = self.profile_for_entry(&entry); let Ok(profile_root) = root_file(&profile) else { self.status_label .set_text("폴더의 저장된 위치 정보를 확인할 수 없습니다."); return; }; let folder = gio::File::for_uri(&entry.uri); if !is_within_root(&folder, &profile_root) { self.status_label .set_text("저장된 위치의 루트 아래 폴더만 열 수 있습니다."); return; } self.cancel_listing(); let (generation, sort_field, sort_direction) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); (state.generation, state.sort_field, state.sort_direction) }; let case_insensitive = self.config.borrow().case_insensitive_sort; self.status_label .set_text("하위 폴더에서 열 수 있는 파일을 찾는 중..."); let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result = enumerate_tree_entries(&profile, &folder, Some(&operation), move |progress| { let Some(controller) = progress_controller.upgrade() else { return; }; if controller.state.borrow().generation == generation { controller.status_label.set_text(&format!( "하위 폴더 검색 중... (폴더 {}개, 항목 {}개, 파일 {}개)", progress.directories, progress.scanned, progress.viewable )); } }) .await; let Some(controller) = controller.upgrade() else { return; }; if controller.state.borrow().generation != generation { return; } match result { Ok((mut entries, progress)) => { entries.retain(|entry| entry.kind.is_viewable()); sort_entries(&mut entries, sort_field, sort_direction, case_insensitive); let Some(initial) = entries.first().cloned() else { controller .status_label .set_text("하위 폴더에 열 수 있는 파일이 없습니다."); return; }; let skipped = if progress.skipped == 0 { String::new() } else { format!(" · 접근할 수 없는 폴더 {}개 건너뜀", progress.skipped) }; controller.status_label.set_text(&format!( "하위 폴더에서 {}개 파일을 찾았습니다{skipped}.", entries.len() )); controller.open_viewable(initial, Rc::new(entries), profile, None); } Err(error) => controller .status_label .set_text(&format!("하위 폴더를 열 수 없습니다: {error:#}")), } }); self.listing_task.replace(Some(task)); } fn favorite_control( self: &Rc, entry: &BrowserEntry, profile: &LocationProfile, ) -> FavoriteControl { let entry = entry.clone(); let profile = profile.clone(); let controller = Rc::downgrade(self); FavoriteControl { active: self.is_favorite(&entry.uri), toggle: Rc::new(move || { let Some(controller) = controller.upgrade() else { return false; }; controller.toggle_favorite(&entry, &profile) }), } } fn download_control(self: &Rc, entry: &BrowserEntry) -> FileDownloadControl { let entry = entry.clone(); let controller = Rc::downgrade(self); FileDownloadControl { download: Rc::new(move |parent, status_label| { let Some(controller) = controller.upgrade() else { return; }; controller.choose_download_destination(entry.clone(), parent, status_label); }), } } fn file_drop_control(self: &Rc) -> FileDropControl { let controller = Rc::downgrade(self); FileDropControl { handle: Rc::new(move |files, parent| { if let Some(controller) = controller.upgrade() { controller.open_dropped_files(files, parent); } }), } } fn open_dropped_files(self: &Rc, files: Vec, parent: gtk::Window) { let [file] = files.as_slice() else { show_drop_error(&parent, "한 번에 하나의 파일 또는 폴더만 열 수 있습니다."); return; }; if !file.is_native() { show_drop_error(&parent, "로컬 파일 또는 폴더만 드래그해서 열 수 있습니다."); return; } let file = file.clone(); self.cancel_listing(); let generation = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.generation }; self.status_label.set_text("열 항목을 확인하는 중..."); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result: Result<_> = async { let info = file .query_info_future( "standard::type,standard::display-name", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT, ) .await .context("열 항목을 확인할 수 없습니다")?; let selected = match info.file_type() { gio::FileType::Directory => None, gio::FileType::Regular => { if !classify_file(Path::new(info.display_name().as_str())).is_viewable() { bail!("지원하지 않는 파일 형식입니다: {}", info.display_name()); } Some(file.clone()) } _ => bail!("일반 파일 또는 폴더만 열 수 있습니다"), }; let directory = selected .as_ref() .and_then(gio::File::parent) .unwrap_or_else(|| file.clone()); let profile = local_profile_for_directory(&directory)?; let operation = mount_operation(&profile, &parent); let entries = enumerate_directory(&profile, directory.uri().as_str(), &operation, |_| {}) .await?; Ok((directory, profile, entries, selected)) } .await; let Some(controller) = controller.upgrade() else { return; }; if controller.state.borrow().generation != generation || !parent.is_visible() { return; } let (directory, profile, entries, selected) = match result { Ok(result) => result, Err(error) => { let message = format!("항목을 열 수 없습니다: {error:#}"); controller.status_label.set_text(&message); show_drop_error(&parent, &message); return; } }; controller.show_dropped_directory(&directory, profile.clone(), entries.clone()); let Some(selected) = selected else { return; }; let Some(entry) = entries .iter() .find(|entry| gio::File::for_uri(&entry.uri).equal(&selected)) .cloned() else { show_drop_error(&parent, "파일을 부모 폴더에서 찾을 수 없습니다."); return; }; let state = controller.state.borrow(); let Some((siblings, _selected_index)) = sorted_navigable_selection( &entries, &entry.uri, state.sort_field, state.sort_direction, controller.config.borrow().case_insensitive_sort, ) else { drop(state); show_drop_error(&parent, "파일의 목록 위치를 확인할 수 없습니다."); return; }; drop(state); controller.open_viewable(entry, Rc::new(siblings), profile, None); }); self.listing_task.replace(Some(task)); } fn show_dropped_directory( &self, directory: &gio::File, profile: LocationProfile, entries: Vec, ) { self.location_dropdown .set_selected(gtk::INVALID_LIST_POSITION); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_editable(true); let uri: String = directory.uri().into(); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(profile); state.mode = BrowserMode::Directory; state.root_uri = uri.clone(); state.current_uri = uri.clone(); state.entries = entries; state.typeahead_query.clear(); state.typeahead_last_input = None; drop(state); self.update_mode_controls(BrowserMode::Directory); self.path_entry.set_text(&display_uri(&uri)); self.search_entry.set_text(""); self.render(); self.window.present(); } fn view_mode_control(self: &Rc) -> ViewModeControl { let selected = self.config.borrow().viewer_mode.dropdown_index(); let controller = Rc::downgrade(self); ViewModeControl { selected, change: Rc::new(move |index| { let Some(controller) = controller.upgrade() else { return; }; let viewer_mode = ViewerMode::from_dropdown_index(index); if controller.config.borrow().viewer_mode == viewer_mode { return; } let mut updated = controller.config.borrow().clone(); updated.viewer_mode = viewer_mode; if let Err(error) = updated.save(&controller.config_path) { controller .status_label .set_text(&format!("보기 모드를 저장할 수 없습니다: {error:#}")); return; } *controller.config.borrow_mut() = updated; }), } } fn two_page_direction_control(self: &Rc) -> TwoPageDirectionControl { let selected = u32::from(self.config.borrow().two_page_right_to_left); let controller = Rc::downgrade(self); TwoPageDirectionControl { selected, change: Rc::new(move |index| { let Some(controller) = controller.upgrade() else { return false; }; let right_to_left = index == 1; if controller.config.borrow().two_page_right_to_left == right_to_left { return true; } let mut updated = controller.config.borrow().clone(); updated.two_page_right_to_left = right_to_left; if let Err(error) = updated.save(&controller.config_path) { controller .status_label .set_text(&format!("2페이지 방향을 저장할 수 없습니다: {error:#}")); return false; } *controller.config.borrow_mut() = updated; true }), } } fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); let active = toggle_favorite_entry(&mut updated, entry, profile); if let Err(error) = updated.save(&self.config_path) { self.status_label .set_text(&format!("즐겨찾기를 저장할 수 없습니다: {error:#}")); return was_active; } *self.config.borrow_mut() = updated; if self.state.borrow().mode == BrowserMode::Favorites { self.show_favorites(); } self.status_label.set_text(if active { "즐겨찾기에 추가했습니다." } else { "즐겨찾기에서 제거했습니다." }); active } fn record_recent_file(&self, entry: &BrowserEntry, profile: &LocationProfile) { let mut updated = self.config.borrow().clone(); record_recent_file(&mut updated, entry, profile); if let Err(error) = updated.save(&self.config_path) { self.status_label .set_text(&format!("최근 파일 기록을 저장할 수 없습니다: {error:#}")); return; } *self.config.borrow_mut() = updated; if self.state.borrow().mode == BrowserMode::Recent { let entries = self .config .borrow() .recent_files .iter() .map(recent_file_browser_entry) .collect(); self.state.borrow_mut().entries = entries; self.render(); } } fn choose_download_destination( self: &Rc, entry: BrowserEntry, parent: gtk::Window, status_label: gtk::Label, ) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") .initial_name(&entry.name) .build(); let controller = Rc::downgrade(self); glib::MainContext::default().spawn_local(async move { let result = dialog.save_future(Some(&parent)).await; let Some(controller) = controller.upgrade() else { return; }; match result { Ok(destination) if destination.is_native() => { controller.download_file(entry, destination, status_label); } Ok(_) => status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."), Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {} Err(error) => { status_label.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")); } } }); } fn download_file(&self, entry: BrowserEntry, destination: gio::File, status_label: gtk::Label) { let Some(final_path) = destination.path() else { status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."); return; }; let part_path = download_part_path(&final_path); let source = gio::File::for_uri(&entry.uri); let part_file = gio::File::for_path(&part_path); let progress_label = status_label.clone(); let result_label = status_label; let expected_size = entry.size; source.copy_async( &part_file, gio::FileCopyFlags::OVERWRITE, glib::Priority::DEFAULT, gio::Cancellable::NONE, Some(Box::new(move |current, total| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("{} 다운로드 중... {percent}%", entry.name)); })), move |result| { let completed = result.map_err(anyhow::Error::from).and_then(|()| { let actual_size = fs::metadata(&part_path)?.len(); if expected_size.is_some_and(|expected| expected != actual_size) { anyhow::bail!("다운로드 크기가 원본과 다릅니다."); } fs::rename(&part_path, &final_path)?; Ok(()) }); if let Err(error) = completed { let _ = fs::remove_file(&part_path); result_label.set_text(&format!("다운로드 실패: {error:#}")); } else { result_label.set_text(&format!("다운로드 완료: {}", final_path.display())); } }, ); } fn set_case_insensitive_sort(&self, check: >k::CheckButton) { let active = check.is_active(); let previous = self.config.borrow().case_insensitive_sort; if active == previous { return; } let mut updated = self.config.borrow().clone(); updated.case_insensitive_sort = active; match updated.save(&self.config_path) { Ok(()) => { *self.config.borrow_mut() = updated; self.render(); } Err(error) => { self.status_label .set_text(&format!("정렬 설정을 저장할 수 없습니다: {error:#}")); check.set_active(previous); } } } fn set_fast_image_loading(&self, switch: >k::Switch) { let active = switch.is_active(); let previous = self.config.borrow().fast_image_loading; if active == previous { return; } let mut updated = self.config.borrow().clone(); updated.fast_image_loading = active; match updated.save(&self.config_path) { Ok(()) => { *self.config.borrow_mut() = updated; self.status_label.set_text(if active { "빠른 이미지 로딩을 켰습니다." } else { "빠른 이미지 로딩을 껐습니다." }); } Err(error) => { self.status_label .set_text(&format!("설정을 저장할 수 없습니다: {error:#}")); switch.set_active(previous); } } } fn selected_config_index(&self) -> Option { let index = (self.location_dropdown.selected() as usize).checked_sub(1)?; (index < self.config.borrow().locations.len()).then_some(index) } fn show_about_dialog(&self) { let dialog = gtk::Window::builder() .title("Comicviewer 정보") .transient_for(&self.window) .modal(true) .resizable(false) .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 14); content.set_margin_top(24); content.set_margin_bottom(16); content.set_margin_start(24); content.set_margin_end(24); let name = gtk::Label::new(Some(&format!( "Comicviewer ({})", env!("CARGO_PKG_VERSION") ))); name.add_css_class("title-2"); let contact = gtk::Label::new(Some("개발자 연락처 : burningtuna@gmail.com")); let libraries = gtk::Label::new(Some( "사용한 라이브러리\n\ GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ serde · toml · anyhow · chrono · filetime · percent-encoding\n\ sha2 · tempfile · flate2 · tar", )); libraries.set_xalign(0.0); libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); 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(&update_status); content.append(&actions); dialog.set_child(Some(&content)); dialog.present(); } fn show_clear_cache_dialog(self: &Rc) { 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, edit_index: Option) { let existing = edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned()); let dialog = gtk::Window::builder() .title(if existing.is_some() { "위치 수정" } else { "위치 추가" }) .transient_for(&self.window) .modal(true) .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 12); let grid = gtk::Grid::builder() .row_spacing(8) .column_spacing(10) .margin_top(12) .margin_bottom(12) .margin_start(12) .margin_end(12) .build(); let kind = gtk::DropDown::from_strings(&["로컬", "SMB", "FTP", "SFTP"]); let name = gtk::Entry::new(); let address = gtk::Entry::builder() .placeholder_text("smb://server/share 또는 /home/user/comics") .build(); let username = gtk::Entry::new(); let password = gtk::Entry::builder().visibility(false).build(); let domain = gtk::Entry::new(); if let Some(profile) = &existing { kind.set_selected(location_kind_index(profile.kind)); name.set_text(&profile.name); address.set_text(&profile.address); username.set_text(&profile.username); password.set_text(&profile.password); domain.set_text(&profile.domain); } for (row, label, widget) in [ (0, "종류", kind.clone().upcast::()), (1, "이름", name.clone().upcast()), (2, "주소", address.clone().upcast()), (3, "사용자", username.clone().upcast()), (4, "비밀번호", password.clone().upcast()), (5, "SMB 도메인", domain.clone().upcast()), ] { grid.attach(>k::Label::new(Some(label)), 0, row, 1, 1); grid.attach(&widget, 1, row, 1, 1); } let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); actions.set_halign(gtk::Align::End); actions.set_margin_bottom(12); actions.set_margin_end(12); let cancel_button = gtk::Button::with_label("취소"); let save_button = gtk::Button::with_label("저장"); actions.append(&cancel_button); actions.append(&save_button); content.append(&grid); 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(); save_button.connect_clicked(move |_| { let profile = LocationProfile { name: name.text().into(), kind: match kind.selected() { 1 => LocationKind::Smb, 2 => LocationKind::Ftp, 3 => LocationKind::Sftp, _ => LocationKind::Local, }, address: address.text().into(), username: username.text().into(), password: password.text().into(), domain: domain.text().into(), }; if let Err(error) = validate_profile(&profile) { controller.status_label.set_text(&format!("{error:#}")); return; } let mut updated = controller.config.borrow().clone(); let selected_profile = if let Some(index) = edit_index { let Some(existing) = updated.locations.get_mut(index) else { controller .status_label .set_text("수정할 위치를 찾을 수 없습니다."); return; }; *existing = profile; index + 1 } else { updated.locations.push(profile); updated.locations.len() }; if let Err(error) = updated.save(&controller.config_path) { controller.status_label.set_text(&format!("{error:#}")); return; } *controller.config.borrow_mut() = updated; controller.rebuild_profiles(); controller .location_dropdown .set_selected(selected_profile as u32); dialog_to_close.close(); }); dialog.present(); } fn show_delete_location_dialog(self: &Rc, index: usize) { let Some(profile) = self.config.borrow().locations.get(index).cloned() else { return; }; let dialog = gtk::Window::builder() .title("위치 삭제") .transient_for(&self.window) .modal(true) .default_width(420) .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(&format!( "저장된 위치 '{}'을(를) 삭제하시겠습니까?\n서버의 파일은 삭제되지 않습니다.", profile.name ))); 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 delete_button = gtk::Button::with_label("삭제"); delete_button.add_css_class("destructive-action"); actions.append(&cancel_button); actions.append(&delete_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(); delete_button.connect_clicked(move |_| { let mut updated = controller.config.borrow().clone(); if index >= updated.locations.len() { controller .status_label .set_text("삭제할 위치를 찾을 수 없습니다."); dialog_to_close.close(); return; } updated.locations.remove(index); if let Err(error) = updated.save(&controller.config_path) { controller.status_label.set_text(&format!("{error:#}")); return; } *controller.config.borrow_mut() = updated; controller.rebuild_profiles(); controller.location_dropdown.set_selected(0); controller .status_label .set_text("저장된 위치를 삭제했습니다."); dialog_to_close.close(); }); dialog.present(); } fn show_clear_recent_dialog(self: &Rc) { let dialog = gtk::Window::builder() .title("최근 기록 비우기") .transient_for(&self.window) .modal(true) .resizable(false) .default_width(420) .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( "최근 열어본 파일 기록을 모두 삭제하시겠습니까?\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 |_| { let mut updated = controller.config.borrow().clone(); updated.recent_files.clear(); if let Err(error) = updated.save(&controller.config_path) { controller .status_label .set_text(&format!("최근 기록을 삭제할 수 없습니다: {error:#}")); return; } *controller.config.borrow_mut() = updated; controller.show_recent(); controller .status_label .set_text("최근 기록을 삭제했습니다."); dialog_to_close.close(); }); dialog.present(); } } 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, LocationKind::Smb => 1, LocationKind::Ftp => 2, LocationKind::Sftp => 3, } } fn home_profile() -> LocationProfile { LocationProfile { name: "내 홈".into(), kind: LocationKind::Local, address: std::env::var("HOME").unwrap_or_else(|_| "/".into()), username: String::new(), password: String::new(), domain: String::new(), } } fn local_profile_for_directory(directory: &gio::File) -> Result { let path = directory .path() .context("로컬 폴더 경로를 확인할 수 없습니다")?; let address = path .to_str() .context("UTF-8로 표현할 수 없는 로컬 경로는 열 수 없습니다")?; let name = path .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .unwrap_or(address) .to_owned(); Ok(LocationProfile { name, kind: LocationKind::Local, address: address.to_owned(), username: String::new(), password: String::new(), domain: String::new(), }) } fn show_drop_error(parent: >k::Window, message: &str) { gtk::AlertDialog::builder() .modal(true) .message(message) .build() .show(Some(parent)); } fn display_uri(uri: &str) -> String { let file = gio::File::for_uri(uri); file.path().map_or_else( || display_remote_uri(file.uri().as_str()), |path| path.to_string_lossy().into_owned(), ) } fn entry_content() -> gtk::Box { let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); content.set_margin_top(8); content.set_margin_bottom(8); content.set_margin_start(10); content.set_margin_end(10); content.append(>k::Image::new()); content.append( >k::Label::builder() .hexpand(true) .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::Middle) .build(), ); for _ in 0..2 { let label = gtk::Label::new(None); label.set_width_chars(17); label.set_xalign(1.0); content.append(&label); } content } fn bind_entry_content(content: >k::Box, entry: &BrowserEntry) { let icon_name = match entry.kind { EntryKind::Directory => "folder-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic", EntryKind::Image => "image-x-generic-symbolic", EntryKind::Video => "video-x-generic-symbolic", EntryKind::OtherFile => "text-x-generic-symbolic", }; let Some(icon) = content.first_child().and_downcast::() else { return; }; icon.set_icon_name(Some(icon_name)); let Some(name) = icon.next_sibling().and_downcast::() else { return; }; name.set_text(entry.display_name()); let Some(modified) = name.next_sibling().and_downcast::() else { return; }; modified.set_text(&format_time(entry.modified)); let Some(created) = modified.next_sibling().and_downcast::() else { return; }; created.set_text(&format_time(entry.created)); } fn format_time(time: Option) -> String { time.map(|time| { let datetime: DateTime = time.into(); datetime.format("%Y-%m-%d %H:%M").to_string() }) .unwrap_or_else(|| "-".into()) } fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Option { if entries.is_empty() { return None; } let query = query.to_lowercase(); (0..entries.len()) .map(|offset| (start + offset) % entries.len()) .find(|index| { entries[*index] .display_name() .to_lowercase() .starts_with(&query) }) } fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { current.is_viewable() && candidate.is_viewable() } fn browser_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { lowercase_query.is_empty() || entry.name.to_lowercase().contains(lowercase_query) } fn special_browser_mode(selected: u32, profile_count: usize) -> Option { if selected == gtk::INVALID_LIST_POSITION { return None; } match selected as usize { index if index == profile_count => Some(BrowserMode::Favorites), index if index == profile_count + 1 => Some(BrowserMode::Recent), _ => None, } } fn sorted_navigable_entries( entries: &[BrowserEntry], current_kind: EntryKind, sort_field: SortField, sort_direction: SortDirection, case_insensitive: bool, ) -> Vec { let mut siblings = entries .iter() .filter(|entry| navigable_sibling(current_kind, entry.kind)) .cloned() .collect::>(); sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive); siblings } fn sorted_navigable_selection( entries: &[BrowserEntry], selected_uri: &str, sort_field: SortField, sort_direction: SortDirection, case_insensitive: bool, ) -> Option<(Vec, usize)> { let entries = sorted_navigable_entries( entries, EntryKind::Image, sort_field, sort_direction, case_insensitive, ); let selected = entries.iter().position(|entry| entry.uri == selected_uri)?; Some((entries, selected)) } fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), name: favorite.name.clone(), list_name: None, kind: if favorite.is_directory { EntryKind::Directory } else { classify_file(Path::new(&favorite.name)) }, size: None, modified: None, created: None, etag: None, } } fn recent_file_browser_entry(recent: &RecentFileEntry) -> BrowserEntry { BrowserEntry { uri: recent.uri.clone(), name: recent.name.clone(), list_name: None, kind: classify_file(Path::new(&recent.name)), size: None, modified: None, created: None, etag: None, } } fn record_recent_file(config: &mut AppConfig, entry: &BrowserEntry, profile: &LocationProfile) { config.recent_files.retain(|recent| recent.uri != entry.uri); config.recent_files.insert( 0, RecentFileEntry { name: entry.name.clone(), uri: entry.uri.clone(), profile: profile.clone(), }, ); config.recent_files.truncate(RECENT_FILE_LIMIT); } fn show_viewer_loading( window: >k::ApplicationWindow, file_name: &str, status: &str, ) -> gtk::Label { set_viewer_loading(window, true); window.set_title(Some(file_name)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(file_name)))); set_viewer_header(window, &header); let content = gtk::Box::new(gtk::Orientation::Vertical, 12); content.set_halign(gtk::Align::Center); content.set_valign(gtk::Align::Center); content.set_hexpand(true); content.set_vexpand(true); let spinner = gtk::Spinner::new(); spinner.set_spinning(true); let name_label = gtk::Label::new(Some(file_name)); name_label.add_css_class("title-2"); name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); name_label.set_max_width_chars(60); let status_label = gtk::Label::new(Some(status)); content.append(&spinner); content.append(&name_label); content.append(&status_label); window.set_child(Some(&content)); status_label } fn download_part_path(final_path: &std::path::Path) -> PathBuf { final_path.with_file_name(format!( "{}.part", final_path .file_name() .map_or_else(|| "download".into(), |name| name.to_string_lossy()) )) } fn toggle_favorite_entry( config: &mut AppConfig, entry: &BrowserEntry, profile: &LocationProfile, ) -> bool { if let Some(index) = config .favorites .iter() .position(|favorite| favorite.uri == entry.uri) { config.favorites.remove(index); false } else { config.favorites.push(FavoriteEntry { name: entry.name.clone(), uri: entry.uri.clone(), is_directory: entry.kind == EntryKind::Directory, profile: profile.clone(), }); true } } #[cfg(test)] mod tests { use super::*; fn entry(name: &str) -> BrowserEntry { BrowserEntry { uri: format!("file:///{name}"), name: name.into(), list_name: None, kind: EntryKind::Directory, size: None, modified: None, created: None, etag: None, } } fn media_entry(name: &str) -> BrowserEntry { let mut entry = entry(name); entry.kind = EntryKind::Image; entry } #[test] fn typeahead_matches_case_insensitively() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; assert_eq!(find_prefix_index(&entries, "br", 0), Some(2)); assert_eq!(find_prefix_index(&entries, "AL", 0), Some(0)); } #[test] fn repeated_typeahead_wraps_to_next_match() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; assert_eq!(find_prefix_index(&entries, "b", 2), Some(2)); assert_eq!(find_prefix_index(&entries, "b", 3), Some(1)); } #[test] fn viewer_navigation_includes_all_viewable_files() { assert!(navigable_sibling(EntryKind::Image, EntryKind::Video)); assert!(navigable_sibling(EntryKind::Video, EntryKind::Image)); 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::Directory, EntryKind::Image)); } #[test] fn media_navigation_recalculates_position_for_case_sorting() { let entries = [media_entry("a2.jpg"), media_entry("A10.jpg")]; let insensitive = sorted_navigable_entries( &entries, EntryKind::Image, SortField::Name, SortDirection::Ascending, true, ); let sensitive = sorted_navigable_entries( &entries, EntryKind::Image, SortField::Name, SortDirection::Ascending, false, ); assert_eq!(insensitive[0].name, "a2.jpg"); assert_eq!(sensitive[0].name, "A10.jpg"); assert_eq!( insensitive.iter().position(|entry| entry.name == "a2.jpg"), Some(0) ); assert_eq!( sensitive.iter().position(|entry| entry.name == "a2.jpg"), Some(1) ); } #[test] fn dropped_file_selection_uses_sorted_parent_index() { let entries = [ media_entry("page10.jpg"), entry("folder"), media_entry("page2.jpg"), ]; let selected_uri = entries[0].uri.clone(); let (siblings, selected) = sorted_navigable_selection( &entries, &selected_uri, SortField::Name, SortDirection::Ascending, true, ) .unwrap(); assert_eq!(siblings.len(), 2); assert_eq!(siblings[0].name, "page2.jpg"); assert_eq!(siblings[selected].name, "page10.jpg"); } #[test] fn mouse_back_button_matches_standard_x11_button() { assert_eq!(MOUSE_BACK_BUTTON, 8); } #[test] fn dropped_directory_creates_local_root_profile() { let directory = tempfile::tempdir().unwrap(); let file = gio::File::for_path(directory.path()); let profile = local_profile_for_directory(&file).unwrap(); assert_eq!(profile.kind, LocationKind::Local); assert_eq!(Path::new(&profile.address), directory.path()); assert!(profile.username.is_empty()); assert!(profile.password.is_empty()); } #[test] fn browser_search_filters_all_current_directory_entry_kinds() { let folder = entry("Comics"); let image = media_entry("cover.jpg"); assert!(browser_search_match(&folder, "comic")); assert!(browser_search_match(&image, "cover")); assert!(!browser_search_match(&image, "comic")); assert!(browser_search_match(&image, "")); let mut nested = media_entry("cover.jpg"); nested.list_name = Some("Comics/cover.jpg".into()); 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(); let folder = favorite_browser_entry(&FavoriteEntry { name: "Comics".into(), uri: "file:///tmp/Comics".into(), is_directory: true, profile: profile.clone(), }); let archive = favorite_browser_entry(&FavoriteEntry { name: "book.zip".into(), uri: "file:///tmp/book.zip".into(), is_directory: false, profile, }); assert_eq!(folder.kind, EntryKind::Directory); assert_eq!(archive.kind, EntryKind::ZipArchive); assert_eq!(special_browser_mode(3, 3), Some(BrowserMode::Favorites)); assert_eq!(special_browser_mode(4, 3), Some(BrowserMode::Recent)); assert_eq!(special_browser_mode(2, 3), None); assert_eq!(special_browser_mode(gtk::INVALID_LIST_POSITION, 3), None); } #[test] fn recent_files_move_to_front_and_keep_only_100() { let profile = LocationProfile { name: "원격".into(), kind: LocationKind::Sftp, address: "sftp://example.invalid/comics".into(), username: "reader".into(), password: "password".into(), domain: String::new(), }; let mut config = AppConfig::default(); for index in 0..=RECENT_FILE_LIMIT { let entry = media_entry(&format!("page{index}.jpg")); record_recent_file(&mut config, &entry, &profile); } assert_eq!(config.recent_files.len(), RECENT_FILE_LIMIT); assert_eq!(config.recent_files[0].name, "page100.jpg"); assert!( !config .recent_files .iter() .any(|recent| recent.name == "page0.jpg") ); let entry = media_entry("page50.jpg"); record_recent_file(&mut config, &entry, &profile); assert_eq!(config.recent_files.len(), RECENT_FILE_LIMIT); assert_eq!(config.recent_files[0].name, "page50.jpg"); assert_eq!(config.recent_files[0].profile.password, "password"); } #[test] fn recent_files_support_all_viewer_file_types() { let profile = home_profile(); let mut config = AppConfig::default(); for name in ["book.zip", "page.jpg", "clip.mp4"] { let mut entry = media_entry(name); entry.kind = classify_file(Path::new(name)); record_recent_file(&mut config, &entry, &profile); } let kinds = config .recent_files .iter() .map(recent_file_browser_entry) .map(|entry| entry.kind) .collect::>(); assert_eq!( kinds, [EntryKind::Video, EntryKind::Image, EntryKind::ZipArchive] ); } #[test] fn incomplete_download_uses_distinct_suffix() { assert_eq!( download_part_path(std::path::Path::new("/tmp/만화.zip")), PathBuf::from("/tmp/만화.zip.part") ); } #[test] fn favorite_toggle_adds_and_removes_the_same_uri() { let mut config = AppConfig::default(); let entry = entry("Comics"); let profile = home_profile(); assert!(toggle_favorite_entry(&mut config, &entry, &profile)); assert_eq!(config.favorites.len(), 1); assert!(config.favorites[0].is_directory); assert_eq!(config.favorites[0].profile.address, profile.address); assert!(!toggle_favorite_entry(&mut config, &entry, &profile)); assert!(config.favorites.is_empty()); } }