- PNG chunk parsing overflow protection with checked arithmetic - Font directory traversal bounded with global result limit - find_unique_path TOCTOU race fixed with create_new + marker byte - Watch mode "processed" dir exclusion narrowed to prevent false skips - Metadata copy now checks format support before little_exif calls - Clipboard temp files cleaned up on app exit - Atomic writes for file manager integration scripts - BMP format support added to encoder and convert step - Regex DoS protection with DFA size limit - Watermark NaN/negative scale guard - Selective EXIF stripping for privacy/custom metadata modes - CLI watch mode: file stability checks, per-file history saves - High contrast toggle preserves and restores original theme - Image list deduplication uses O(1) HashSet lookups - Saturation/trim/padding overflow guards in adjustments
342 lines
10 KiB
Rust
342 lines
10 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::config::AppConfig;
|
|
use crate::error::{PixstripError, Result};
|
|
use crate::preset::Preset;
|
|
|
|
fn default_config_dir() -> PathBuf {
|
|
dirs::config_dir()
|
|
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("pixstrip")
|
|
}
|
|
|
|
/// Write to a temporary file then rename, for crash safety.
|
|
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
let tmp = path.with_extension("tmp");
|
|
std::fs::write(&tmp, contents)?;
|
|
std::fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn sanitize_filename(name: &str) -> String {
|
|
name.chars()
|
|
.map(|c| match c {
|
|
'/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_',
|
|
_ => c,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// --- Preset Store ---
|
|
|
|
pub struct PresetStore {
|
|
presets_dir: PathBuf,
|
|
}
|
|
|
|
impl PresetStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
presets_dir: default_config_dir().join("presets"),
|
|
}
|
|
}
|
|
|
|
pub fn with_base_dir(base: &Path) -> Self {
|
|
Self {
|
|
presets_dir: base.join("presets"),
|
|
}
|
|
}
|
|
|
|
fn ensure_dir(&self) -> Result<()> {
|
|
std::fs::create_dir_all(&self.presets_dir).map_err(PixstripError::Io)
|
|
}
|
|
|
|
fn preset_path(&self, name: &str) -> PathBuf {
|
|
let filename = format!("{}.json", sanitize_filename(name));
|
|
self.presets_dir.join(filename)
|
|
}
|
|
|
|
pub fn save(&self, preset: &Preset) -> Result<()> {
|
|
self.ensure_dir()?;
|
|
let path = self.preset_path(&preset.name);
|
|
let json = serde_json::to_string_pretty(preset)
|
|
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
|
atomic_write(&path, &json).map_err(PixstripError::Io)
|
|
}
|
|
|
|
pub fn load(&self, name: &str) -> Result<Preset> {
|
|
let path = self.preset_path(name);
|
|
if !path.exists() {
|
|
return Err(PixstripError::Preset(format!(
|
|
"Preset '{}' not found",
|
|
name
|
|
)));
|
|
}
|
|
let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?;
|
|
serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))
|
|
}
|
|
|
|
pub fn list(&self) -> Result<Vec<Preset>> {
|
|
if !self.presets_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut presets = Vec::new();
|
|
for entry in std::fs::read_dir(&self.presets_dir).map_err(PixstripError::Io)? {
|
|
let entry = entry.map_err(PixstripError::Io)?;
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
|
let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?;
|
|
if let Ok(preset) = serde_json::from_str::<Preset>(&data) {
|
|
presets.push(preset);
|
|
}
|
|
}
|
|
}
|
|
presets.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(presets)
|
|
}
|
|
|
|
pub fn delete(&self, name: &str) -> Result<()> {
|
|
let path = self.preset_path(name);
|
|
if !path.exists() {
|
|
return Err(PixstripError::Preset(format!(
|
|
"Preset '{}' not found",
|
|
name
|
|
)));
|
|
}
|
|
std::fs::remove_file(&path).map_err(PixstripError::Io)
|
|
}
|
|
|
|
pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> {
|
|
let json = serde_json::to_string_pretty(preset)
|
|
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
|
atomic_write(path, &json).map_err(PixstripError::Io)
|
|
}
|
|
|
|
pub fn import_from_file(&self, path: &Path) -> Result<Preset> {
|
|
let data = std::fs::read_to_string(path).map_err(PixstripError::Io)?;
|
|
let mut preset: Preset =
|
|
serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))?;
|
|
preset.is_custom = true;
|
|
Ok(preset)
|
|
}
|
|
}
|
|
|
|
impl Default for PresetStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// --- Config Store ---
|
|
|
|
pub struct ConfigStore {
|
|
config_path: PathBuf,
|
|
}
|
|
|
|
impl ConfigStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
config_path: default_config_dir().join("config.json"),
|
|
}
|
|
}
|
|
|
|
pub fn with_base_dir(base: &Path) -> Self {
|
|
Self {
|
|
config_path: base.join("config.json"),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self, config: &AppConfig) -> Result<()> {
|
|
if let Some(parent) = self.config_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(config)
|
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
|
atomic_write(&self.config_path, &json).map_err(PixstripError::Io)
|
|
}
|
|
|
|
pub fn load(&self) -> Result<AppConfig> {
|
|
if !self.config_path.exists() {
|
|
return Ok(AppConfig::default());
|
|
}
|
|
let data = std::fs::read_to_string(&self.config_path).map_err(PixstripError::Io)?;
|
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
|
}
|
|
}
|
|
|
|
impl Default for ConfigStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// --- Session Store ---
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct SessionState {
|
|
pub last_input_dir: Option<String>,
|
|
pub last_output_dir: Option<String>,
|
|
pub last_preset_name: Option<String>,
|
|
pub current_step: u32,
|
|
pub window_width: Option<i32>,
|
|
pub window_height: Option<i32>,
|
|
pub window_maximized: bool,
|
|
// Last-used wizard settings
|
|
pub resize_enabled: Option<bool>,
|
|
pub resize_width: Option<u32>,
|
|
pub resize_height: Option<u32>,
|
|
pub convert_enabled: Option<bool>,
|
|
pub convert_format: Option<String>,
|
|
pub compress_enabled: Option<bool>,
|
|
pub quality_preset: Option<String>,
|
|
pub metadata_enabled: Option<bool>,
|
|
pub metadata_mode: Option<String>,
|
|
pub watermark_enabled: Option<bool>,
|
|
pub rename_enabled: Option<bool>,
|
|
pub last_seen_version: Option<String>,
|
|
pub expanded_sections: std::collections::HashMap<String, bool>,
|
|
}
|
|
|
|
pub struct SessionStore {
|
|
session_path: PathBuf,
|
|
}
|
|
|
|
impl SessionStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
session_path: default_config_dir().join("session.json"),
|
|
}
|
|
}
|
|
|
|
pub fn with_base_dir(base: &Path) -> Self {
|
|
Self {
|
|
session_path: base.join("session.json"),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self, state: &SessionState) -> Result<()> {
|
|
if let Some(parent) = self.session_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(state)
|
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
|
atomic_write(&self.session_path, &json).map_err(PixstripError::Io)
|
|
}
|
|
|
|
pub fn load(&self) -> Result<SessionState> {
|
|
if !self.session_path.exists() {
|
|
return Ok(SessionState::default());
|
|
}
|
|
let data = std::fs::read_to_string(&self.session_path).map_err(PixstripError::Io)?;
|
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
|
}
|
|
}
|
|
|
|
impl Default for SessionStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// --- History Store ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HistoryEntry {
|
|
pub timestamp: String,
|
|
pub input_dir: String,
|
|
pub output_dir: String,
|
|
pub preset_name: Option<String>,
|
|
pub total: usize,
|
|
pub succeeded: usize,
|
|
pub failed: usize,
|
|
pub total_input_bytes: u64,
|
|
pub total_output_bytes: u64,
|
|
pub elapsed_ms: u64,
|
|
pub output_files: Vec<String>,
|
|
}
|
|
|
|
pub struct HistoryStore {
|
|
history_path: PathBuf,
|
|
}
|
|
|
|
impl HistoryStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
history_path: default_config_dir().join("history.json"),
|
|
}
|
|
}
|
|
|
|
pub fn with_base_dir(base: &Path) -> Self {
|
|
Self {
|
|
history_path: base.join("history.json"),
|
|
}
|
|
}
|
|
|
|
pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> {
|
|
let mut entries = self.list()?;
|
|
entries.push(entry);
|
|
self.write_all(&entries)?;
|
|
self.prune(max_entries, max_days)
|
|
}
|
|
|
|
pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> {
|
|
let mut entries = self.list()?;
|
|
if entries.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400);
|
|
|
|
// Remove entries older than max_days (keep entries with unparseable timestamps)
|
|
entries.retain(|e| {
|
|
e.timestamp.parse::<u64>().map_or(true, |ts| ts >= cutoff_secs)
|
|
});
|
|
|
|
// Trim to max_entries (keep the most recent)
|
|
if entries.len() > max_entries {
|
|
let start = entries.len() - max_entries;
|
|
entries = entries.split_off(start);
|
|
}
|
|
|
|
self.write_all(&entries)
|
|
}
|
|
|
|
pub fn list(&self) -> Result<Vec<HistoryEntry>> {
|
|
if !self.history_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let data = std::fs::read_to_string(&self.history_path).map_err(PixstripError::Io)?;
|
|
if data.trim().is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
|
}
|
|
|
|
pub fn clear(&self) -> Result<()> {
|
|
self.write_all(&Vec::<HistoryEntry>::new())
|
|
}
|
|
|
|
pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
|
if let Some(parent) = self.history_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
|
}
|
|
let json = serde_json::to_string_pretty(entries)
|
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
|
atomic_write(&self.history_path, &json).map_err(PixstripError::Io)
|
|
}
|
|
}
|
|
|
|
impl Default for HistoryStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|