Initial Comicviewer implementation

This commit is contained in:
burnintuna 2026-07-21 02:12:05 +09:00
commit cb01395612
20 changed files with 4940 additions and 0 deletions

152
src/config.rs Normal file
View file

@ -0,0 +1,152 @@
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 locations: Vec<LocationProfile>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
cache_quota_gb: 10,
fast_image_loading: true,
locations: 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, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocationKind {
Local,
Smb,
Ftp,
Sftp,
}
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);
}
#[test]
fn location_credentials_round_trip_as_plain_text() {
let config = AppConfig {
cache_quota_gb: 0,
fast_image_loading: 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(),
}],
};
let encoded = toml::to_string(&config).unwrap();
let decoded: AppConfig = toml::from_str(&encoded).unwrap();
assert_eq!(decoded.locations[0].password, "plain-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);
}
}