251 lines
7.5 KiB
Rust
251 lines
7.5 KiB
Rust
use std::fs::{self, OpenOptions};
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct AppConfig {
|
|
pub cache_quota_gb: u64,
|
|
pub fast_image_loading: bool,
|
|
pub case_insensitive_sort: bool,
|
|
pub viewer_mode: ViewerMode,
|
|
pub two_page_right_to_left: bool,
|
|
pub locations: Vec<LocationProfile>,
|
|
pub favorites: Vec<FavoriteEntry>,
|
|
}
|
|
|
|
impl Default for AppConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
cache_quota_gb: 10,
|
|
fast_image_loading: true,
|
|
case_insensitive_sort: true,
|
|
viewer_mode: ViewerMode::FitWidth,
|
|
two_page_right_to_left: true,
|
|
locations: Vec::new(),
|
|
favorites: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocationProfile {
|
|
pub name: String,
|
|
pub kind: LocationKind,
|
|
pub address: String,
|
|
#[serde(default)]
|
|
pub username: String,
|
|
#[serde(default)]
|
|
pub password: String,
|
|
#[serde(default)]
|
|
pub domain: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FavoriteEntry {
|
|
pub name: String,
|
|
pub uri: String,
|
|
pub is_directory: bool,
|
|
pub profile: LocationProfile,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum LocationKind {
|
|
Local,
|
|
Smb,
|
|
Ftp,
|
|
Sftp,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ViewerMode {
|
|
ActualSize,
|
|
#[default]
|
|
FitWidth,
|
|
FitHeight,
|
|
TwoPage,
|
|
Continuous,
|
|
}
|
|
|
|
impl ViewerMode {
|
|
pub fn dropdown_index(self) -> u32 {
|
|
match self {
|
|
Self::ActualSize => 0,
|
|
Self::FitWidth => 1,
|
|
Self::FitHeight => 2,
|
|
Self::TwoPage => 3,
|
|
Self::Continuous => 4,
|
|
}
|
|
}
|
|
|
|
pub fn from_dropdown_index(index: u32) -> Self {
|
|
match index {
|
|
0 => Self::ActualSize,
|
|
2 => Self::FitHeight,
|
|
3 => Self::TwoPage,
|
|
4 => Self::Continuous,
|
|
_ => Self::FitWidth,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppConfig {
|
|
pub fn load_or_create(path: &Path) -> Result<Self> {
|
|
if path.exists() {
|
|
restrict_file_permissions(path)?;
|
|
let contents = fs::read_to_string(path)
|
|
.with_context(|| format!("설정 파일을 읽을 수 없습니다: {}", path.display()))?;
|
|
let config = toml::from_str(&contents)
|
|
.with_context(|| format!("설정 파일 형식이 잘못되었습니다: {}", path.display()))?;
|
|
return Ok(config);
|
|
}
|
|
|
|
let config = Self::default();
|
|
config.save(path)?;
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn save(&self, path: &Path) -> Result<()> {
|
|
let temporary_path = temporary_path(path);
|
|
let contents = toml::to_string_pretty(self).context("설정을 직렬화할 수 없습니다")?;
|
|
|
|
let write_result = write_private_file(&temporary_path, contents.as_bytes())
|
|
.and_then(|()| fs::rename(&temporary_path, path).map_err(Into::into));
|
|
|
|
if write_result.is_err() {
|
|
let _ = fs::remove_file(&temporary_path);
|
|
}
|
|
write_result
|
|
.with_context(|| format!("설정 파일을 저장할 수 없습니다: {}", path.display()))?;
|
|
restrict_file_permissions(path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn temporary_path(path: &Path) -> PathBuf {
|
|
let mut file_name = path
|
|
.file_name()
|
|
.map(ToOwned::to_owned)
|
|
.unwrap_or_else(|| "config".into());
|
|
file_name.push(".tmp");
|
|
path.with_file_name(file_name)
|
|
}
|
|
|
|
fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> {
|
|
let mut options = OpenOptions::new();
|
|
options.write(true).create(true).truncate(true);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600);
|
|
}
|
|
|
|
let mut file = options.open(path)?;
|
|
file.write_all(contents)?;
|
|
file.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn restrict_file_permissions(path: &Path) -> Result<()> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let mut permissions = fs::metadata(path)?.permissions();
|
|
permissions.set_mode(0o600);
|
|
fs::set_permissions(path, permissions)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_cache_quota_is_ten_gigabytes() {
|
|
assert_eq!(AppConfig::default().cache_quota_gb, 10);
|
|
assert!(AppConfig::default().case_insensitive_sort);
|
|
}
|
|
|
|
#[test]
|
|
fn location_credentials_round_trip_as_plain_text() {
|
|
let config = AppConfig {
|
|
cache_quota_gb: 0,
|
|
fast_image_loading: true,
|
|
case_insensitive_sort: true,
|
|
viewer_mode: ViewerMode::FitWidth,
|
|
two_page_right_to_left: true,
|
|
locations: vec![LocationProfile {
|
|
name: "테스트".into(),
|
|
kind: LocationKind::Sftp,
|
|
address: "sftp://example.invalid/comics".into(),
|
|
username: "reader".into(),
|
|
password: "plain-password".into(),
|
|
domain: String::new(),
|
|
}],
|
|
favorites: vec![FavoriteEntry {
|
|
name: "원격 만화".into(),
|
|
uri: "sftp://example.invalid/comics/book.zip".into(),
|
|
is_directory: false,
|
|
profile: LocationProfile {
|
|
name: "즐겨찾기 서버".into(),
|
|
kind: LocationKind::Sftp,
|
|
address: "sftp://example.invalid/comics".into(),
|
|
username: "favorite-reader".into(),
|
|
password: "favorite-password".into(),
|
|
domain: String::new(),
|
|
},
|
|
}],
|
|
};
|
|
|
|
let encoded = toml::to_string(&config).unwrap();
|
|
let decoded: AppConfig = toml::from_str(&encoded).unwrap();
|
|
assert_eq!(decoded.locations[0].password, "plain-password");
|
|
assert_eq!(decoded.favorites[0].profile.username, "favorite-reader");
|
|
assert_eq!(decoded.favorites[0].profile.password, "favorite-password");
|
|
}
|
|
|
|
#[test]
|
|
fn fast_image_loading_is_enabled_for_existing_configs() {
|
|
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
|
assert!(decoded.fast_image_loading);
|
|
assert!(decoded.case_insensitive_sort);
|
|
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
|
|
assert!(decoded.two_page_right_to_left);
|
|
assert!(decoded.favorites.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn viewer_modes_round_trip_through_dropdown_indices() {
|
|
for mode in [
|
|
ViewerMode::ActualSize,
|
|
ViewerMode::FitWidth,
|
|
ViewerMode::FitHeight,
|
|
ViewerMode::TwoPage,
|
|
ViewerMode::Continuous,
|
|
] {
|
|
assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode);
|
|
}
|
|
assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth);
|
|
}
|
|
|
|
#[test]
|
|
fn two_page_direction_defaults_to_rtl_and_round_trips_ltr() {
|
|
let existing: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
|
assert!(existing.two_page_right_to_left);
|
|
|
|
let config = AppConfig {
|
|
two_page_right_to_left: false,
|
|
..AppConfig::default()
|
|
};
|
|
let decoded: AppConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
|
|
assert!(!decoded.two_page_right_to_left);
|
|
}
|
|
}
|