Initial Comicviewer implementation
This commit is contained in:
commit
cb01395612
20 changed files with 4940 additions and 0 deletions
275
src/source.rs
Normal file
275
src/source.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use std::path::Path;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gtk::gio;
|
||||
use gtk::gio::prelude::*;
|
||||
use gtk::glib;
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
use crate::browser::{BrowserEntry, EntryKind, is_zip_path};
|
||||
use crate::config::{LocationKind, LocationProfile};
|
||||
|
||||
const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value";
|
||||
|
||||
pub fn root_file(profile: &LocationProfile) -> Result<gio::File> {
|
||||
validate_profile(profile)?;
|
||||
Ok(file_from_user_input(profile.kind, &profile.address))
|
||||
}
|
||||
|
||||
pub fn file_from_user_input(kind: LocationKind, input: &str) -> gio::File {
|
||||
match kind {
|
||||
LocationKind::Local => gio::File::for_path(input),
|
||||
_ => gio::File::for_uri(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_remote_uri(uri: &str) -> String {
|
||||
percent_decode_str(uri).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub fn is_within_root(file: &gio::File, root: &gio::File) -> bool {
|
||||
if file.equal(root) || file.has_prefix(root) {
|
||||
return true;
|
||||
}
|
||||
let mut root_uri = root.uri().to_string();
|
||||
if !root_uri.ends_with('/') {
|
||||
root_uri.push('/');
|
||||
}
|
||||
file.uri().starts_with(&root_uri)
|
||||
}
|
||||
|
||||
pub fn parent_within_root(current: &gio::File, root: &gio::File) -> Option<gio::File> {
|
||||
if current.equal(root) {
|
||||
return None;
|
||||
}
|
||||
if let Some(parent) = current.parent()
|
||||
&& is_within_root(&parent, root)
|
||||
{
|
||||
return Some(parent);
|
||||
}
|
||||
is_within_root(current, root).then(|| root.clone())
|
||||
}
|
||||
|
||||
pub fn validate_profile(profile: &LocationProfile) -> Result<()> {
|
||||
if profile.name.trim().is_empty() || profile.address.trim().is_empty() {
|
||||
bail!("위치 이름과 주소를 입력해 주세요.");
|
||||
}
|
||||
match profile.kind {
|
||||
LocationKind::Local if !Path::new(&profile.address).is_absolute() => {
|
||||
bail!("로컬 위치는 절대 경로여야 합니다.")
|
||||
}
|
||||
LocationKind::Smb if !profile.address.starts_with("smb://") => {
|
||||
bail!("SMB 주소는 smb://로 시작해야 합니다.")
|
||||
}
|
||||
LocationKind::Ftp if !profile.address.starts_with("ftp://") => {
|
||||
bail!("FTP 주소는 ftp://로 시작해야 합니다.")
|
||||
}
|
||||
LocationKind::Sftp if !profile.address.starts_with("sftp://") => {
|
||||
bail!("SFTP 주소는 sftp://로 시작해야 합니다.")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn enumerate_directory<F>(
|
||||
profile: &LocationProfile,
|
||||
directory_uri: &str,
|
||||
operation: >k::MountOperation,
|
||||
mut progress: F,
|
||||
) -> Result<Vec<BrowserEntry>>
|
||||
where
|
||||
F: FnMut(usize),
|
||||
{
|
||||
let directory = gio::File::for_uri(directory_uri);
|
||||
let enumerator = match enumerate_with_mount_retry(
|
||||
&directory,
|
||||
operation,
|
||||
profile.kind != LocationKind::Local,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(enumerator) => enumerator,
|
||||
Err(error) if error.matches(gio::IOErrorEnum::NotSupported) => {
|
||||
bail!("원격 파일 시스템 백엔드가 없습니다. gvfs와 gvfs-smb를 설치해 주세요: {error}")
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("폴더에 접근할 수 없습니다: {directory_uri}"));
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut scanned = 0;
|
||||
|
||||
loop {
|
||||
let batch = enumerator
|
||||
.next_files_future(128, glib::Priority::DEFAULT)
|
||||
.await?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
scanned += batch.len();
|
||||
progress(scanned);
|
||||
for info in batch {
|
||||
let kind = match info.file_type() {
|
||||
gio::FileType::Directory => EntryKind::Directory,
|
||||
gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory,
|
||||
gio::FileType::Regular if is_zip_path(Path::new(info.display_name().as_str())) => {
|
||||
EntryKind::ZipArchive
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let child = enumerator.child(&info);
|
||||
let uri = info
|
||||
.attribute_string("standard::target-uri")
|
||||
.map_or_else(|| child.uri().into(), Into::into);
|
||||
entries.push(BrowserEntry {
|
||||
uri,
|
||||
name: info.display_name().into(),
|
||||
kind,
|
||||
size: info
|
||||
.has_attribute("standard::size")
|
||||
.then(|| info.size())
|
||||
.filter(|size| *size >= 0)
|
||||
.map(|size| size as u64),
|
||||
modified: attribute_time(&info, "time::modified"),
|
||||
created: attribute_time(&info, "time::created"),
|
||||
etag: info
|
||||
.has_attribute("etag::value")
|
||||
.then(|| info.etag())
|
||||
.flatten()
|
||||
.map(Into::into),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn mount_operation(
|
||||
profile: &LocationProfile,
|
||||
parent: &impl glib::object::IsA<gtk::Window>,
|
||||
) -> gtk::MountOperation {
|
||||
let operation = gtk::MountOperation::new(Some(parent));
|
||||
let username = profile.username.clone();
|
||||
let password = profile.password.clone();
|
||||
let domain = profile.domain.clone();
|
||||
operation.connect_ask_password(move |operation, _, _, _, flags| {
|
||||
let missing_username =
|
||||
flags.contains(gio::AskPasswordFlags::NEED_USERNAME) && username.is_empty();
|
||||
let missing_password =
|
||||
flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) && password.is_empty();
|
||||
if missing_username || missing_password {
|
||||
return;
|
||||
}
|
||||
if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) {
|
||||
operation.set_username(Some(&username));
|
||||
}
|
||||
if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) {
|
||||
operation.set_password(Some(&password));
|
||||
}
|
||||
if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) {
|
||||
operation.set_domain(Some(&domain));
|
||||
}
|
||||
operation.set_anonymous(false);
|
||||
operation.set_password_save(gio::PasswordSave::Never);
|
||||
operation.stop_signal_emission_by_name("ask-password");
|
||||
operation.reply(gio::MountOperationResult::Handled);
|
||||
});
|
||||
operation
|
||||
}
|
||||
|
||||
async fn enumerate_with_mount_retry(
|
||||
directory: &gio::File,
|
||||
operation: >k::MountOperation,
|
||||
allow_mount: bool,
|
||||
) -> Result<gio::FileEnumerator, glib::Error> {
|
||||
let first = directory
|
||||
.enumerate_children_future(
|
||||
ATTRIBUTES,
|
||||
gio::FileQueryInfoFlags::NONE,
|
||||
glib::Priority::DEFAULT,
|
||||
)
|
||||
.await;
|
||||
let Err(error) = first else {
|
||||
return first;
|
||||
};
|
||||
if !allow_mount || !error.matches(gio::IOErrorEnum::NotMounted) {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
match directory
|
||||
.mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
directory
|
||||
.enumerate_children_future(
|
||||
ATTRIBUTES,
|
||||
gio::FileQueryInfoFlags::NONE,
|
||||
glib::Priority::DEFAULT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option<std::time::SystemTime> {
|
||||
info.has_attribute(attribute)
|
||||
.then(|| UNIX_EPOCH + Duration::from_secs(info.attribute_uint64(attribute)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_protocol_schemes() {
|
||||
let profile = LocationProfile {
|
||||
name: "서버".into(),
|
||||
kind: LocationKind::Sftp,
|
||||
address: "ftp://example.invalid/comics".into(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
domain: String::new(),
|
||||
};
|
||||
assert!(validate_profile(&profile).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smb_paths_stay_inside_saved_root() {
|
||||
let root = gio::File::for_uri("smb://example.invalid/ComicsA");
|
||||
let child = gio::File::for_uri("smb://example.invalid/ComicsA/favorites");
|
||||
let sibling = gio::File::for_uri("smb://example.invalid/ComicsB");
|
||||
|
||||
assert!(is_within_root(&child, &root));
|
||||
assert!(!is_within_root(&sibling, &root));
|
||||
assert!(parent_within_root(&child, &root).is_some_and(|parent| parent.equal(&root)));
|
||||
assert!(parent_within_root(&root, &root).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_share_can_return_to_smb_server_root() {
|
||||
let root = gio::File::for_uri("smb://example.invalid");
|
||||
let share = gio::File::for_uri("smb://example.invalid/ComicsA");
|
||||
|
||||
assert!(is_within_root(&share, &root));
|
||||
assert!(parent_within_root(&share, &root).is_some_and(|parent| parent.equal(&root)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ftp_parse_name_round_trips_utf8_paths() {
|
||||
let encoded =
|
||||
gio::File::for_uri("ftp://example.invalid/%ED%95%9C%EA%B8%80%20%ED%8F%B4%EB%8D%94");
|
||||
let display_name = display_remote_uri(encoded.uri().as_str());
|
||||
assert!(display_name.contains("한글 폴더"));
|
||||
|
||||
let decoded = file_from_user_input(LocationKind::Ftp, &display_name);
|
||||
assert!(decoded.equal(&encoded));
|
||||
|
||||
let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str());
|
||||
assert!(encoded_input.equal(&encoded));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue