Skip to main content

smriti/config/
mod.rs

1//! Application configuration.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// Application settings.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AppConfig {
10    pub theme: AppTheme,
11    pub thumbnail_size: u32,
12    pub face_detection_confidence: f32,
13    pub face_clustering_threshold: f32,
14    pub burst_time_window_seconds: i64,
15    pub trash_auto_delete_days: u32,
16    pub scan_hidden_folders: bool,
17    #[serde(default = "default_show_timeline_stacks")]
18    pub show_timeline_stacks: bool,
19    pub date_format: DateFormat,
20    pub remembered_drives: Vec<PathBuf>,
21    pub window_width: u32,
22    pub window_height: u32,
23    /// Whether the user's window was maximised when they last closed
24    /// the app. Restored at startup by the Tauri shell.
25    #[serde(default)]
26    pub window_maximized: bool,
27    pub sidebar_collapsed: bool,
28
29    #[serde(default = "default_map_cache_limit_mb")]
30    pub map_cache_limit_mb: u32,
31
32    /// Weight applied to co-occurrence prior when multiple candidate
33    /// clusters exist for an unassigned face. 0 disables the signal.
34    #[serde(default = "default_weight_cooccurrence")]
35    pub weight_cooccurrence: f32,
36
37    /// Weight applied to temporal-neighbor prior (±60 s, same cluster).
38    #[serde(default = "default_weight_temporal")]
39    pub weight_temporal: f32,
40
41    /// Whether the Memories feature (N years ago today, seasonal recaps) is on.
42    #[serde(default = "default_memories_enabled")]
43    pub memories_enabled: bool,
44
45    /// Optional user-specified home city for album suggestions.
46    /// When set, the suggestion engine skips auto-detection of the home city.
47    #[serde(default)]
48    pub home_city_override: Option<String>,
49
50    /// Last-viewed top-level view; restored on app launch.
51    /// Stored as a string for forward compatibility across View enum changes.
52    #[serde(default)]
53    pub last_view: Option<String>,
54
55    /// Opt-in: whether Smriti should query GitHub Releases once
56    /// every 24 hours to see if a new version is available. Off by
57    /// default — the first-run prompt flips it on if the user agrees.
58    /// See PRIVACY.md.
59    #[serde(default)]
60    pub auto_update_check_enabled: bool,
61
62    /// Unix timestamp of the last update check. Used to rate-limit
63    /// the background subscription to at most one check per 24 h.
64    #[serde(default)]
65    pub last_update_check_at_unix: Option<i64>,
66
67    /// True until the user accepts or declines the first-run update
68    /// prompt. After that, toggling is done through Settings.
69    #[serde(default = "default_show_first_run_update_prompt")]
70    pub show_first_run_update_prompt: bool,
71
72    /// Version of the EXIF date-extraction logic the user's library was
73    /// last evaluated against. When the binary's `CURRENT_DATE_LOGIC_VERSION`
74    /// is higher, the app auto-runs `RefreshPhotoDates` once on
75    /// first launch with the upgraded binary so users don't keep
76    /// seeing 2012 ghosts on photos shot in 2026 etc. After the
77    /// refresh runs, this field is bumped to the new version.
78    #[serde(default)]
79    pub date_logic_version: u32,
80
81    /// True once the user has been shown the "Place names off — run
82    /// scripts/setup_assets.sh" toast. Prevents the same toast appearing
83    /// on every drive-select.
84    #[serde(default)]
85    pub geonames_warning_seen: bool,
86
87    /// Disk budget for the on-drive thumbnail cache, expressed in
88    /// gigabytes. Drives the LRU eviction in `ThumbnailService`. Default
89    /// 5 GB suits a mid-size phone library; small-disk users can dial
90    /// down, big-library users up.
91    #[serde(default = "default_thumbnail_cache_gb")]
92    pub thumbnail_cache_gb: f64,
93
94    /// Optional GPU bridge URL for face embedding (opt-in).
95    #[serde(default)]
96    pub face_gpu_bridge_url: Option<String>,
97
98    /// Whether the GPU bridge is explicitly enabled by the user.
99    #[serde(default)]
100    pub face_gpu_bridge_enabled: bool,
101
102    /// Version of config migrations applied. Bumped each time a default
103    /// changes and existing installs need a one-time pick-up.
104    #[serde(default)]
105    pub config_version: u32,
106
107    /// Face embedding model filename (relative to the models directory).
108    /// AdaFace is the only supported local recognizer.
109    #[serde(default = "default_face_embedder_model")]
110    pub face_embedder_model: String,
111
112    /// Enables the album-only in-app Assistant. Semantic search remains
113    /// available when this is off.
114    #[serde(default = "default_assistant_enabled")]
115    pub assistant_enabled: bool,
116
117    /// Top-level opt-in for provider-backed AI features. Local semantic
118    /// search is not gated by this.
119    #[serde(default)]
120    pub ai_features_enabled: bool,
121
122    /// Assistant provider identifier. `local` keeps the deterministic
123    /// album-only harness; `openai_compatible` enables the saved endpoint
124    /// configuration for a provider-backed assistant turn.
125    #[serde(default = "default_assistant_provider")]
126    pub assistant_provider: String,
127
128    /// OpenAI-compatible chat completions base URL.
129    #[serde(default = "default_assistant_base_url")]
130    pub assistant_base_url: String,
131
132    /// Provider model name.
133    #[serde(default = "default_assistant_model")]
134    pub assistant_model: String,
135
136    /// Provider API key. DTOs only expose whether this is set.
137    #[serde(default)]
138    pub assistant_api_key: Option<String>,
139}
140
141/// Bumped any time the EXIF date fallback chain changes. Stored in
142/// `AppConfig::date_logic_version`; on launch, if the saved value is
143/// less than this, the app re-extracts dates for every photo so the
144/// new logic actually takes effect against existing rows.
145///
146/// Bump history:
147///   0 — pre-PR6 chain (DateTimeOriginal -> DateTime -> filename -> mtime)
148///   1 — PR6 chain (DateTimeOriginal -> DateTimeDigitized -> filename -> DateTime -> mtime)
149/// V2 (2026-05-05): stale-EXIF defence. When `DateTime` modification
150/// tag would be the source, sanity-check it against mtime — if they
151/// disagree by ≥ 2 years, prefer mtime (handles phones whose firmware
152/// clock reset to 2012-01-01 and now stamp every photo with that).
153/// Also adds WhatsApp/Snapchat/generic filename patterns and tightens
154/// the catch-all 8-digit regex so ID strings can't masquerade as dates.
155pub const CURRENT_DATE_LOGIC_VERSION: u32 = 2;
156
157fn default_show_first_run_update_prompt() -> bool {
158    true
159}
160
161fn default_weight_cooccurrence() -> f32 {
162    0.30
163}
164
165fn default_weight_temporal() -> f32 {
166    0.50
167}
168
169fn default_memories_enabled() -> bool {
170    true
171}
172
173fn default_map_cache_limit_mb() -> u32 {
174    500
175}
176
177fn default_thumbnail_cache_gb() -> f64 {
178    5.0
179}
180
181fn default_show_timeline_stacks() -> bool {
182    true
183}
184
185fn default_face_embedder_model() -> String {
186    "adaface_ir101_webface12m.onnx".to_string()
187}
188
189fn default_assistant_enabled() -> bool {
190    false
191}
192
193fn default_assistant_provider() -> String {
194    "openai_compatible".to_string()
195}
196
197fn default_assistant_base_url() -> String {
198    "https://openrouter.ai/api/v1".to_string()
199}
200
201fn default_assistant_model() -> String {
202    "deepseek/deepseek-v4-flash".to_string()
203}
204
205impl Default for AppConfig {
206    fn default() -> Self {
207        Self {
208            theme: AppTheme::Dark,
209            thumbnail_size: 500,
210            face_detection_confidence: 0.25,
211            face_clustering_threshold: 0.30,
212            burst_time_window_seconds: 3,
213            trash_auto_delete_days: 30,
214            scan_hidden_folders: false,
215            show_timeline_stacks: true,
216            date_format: DateFormat::Locale,
217            remembered_drives: Vec::new(),
218            window_width: 1600,
219            window_height: 1000,
220            window_maximized: false,
221            sidebar_collapsed: false,
222            map_cache_limit_mb: default_map_cache_limit_mb(),
223            weight_cooccurrence: default_weight_cooccurrence(),
224            weight_temporal: default_weight_temporal(),
225            memories_enabled: default_memories_enabled(),
226            home_city_override: None,
227            last_view: None,
228            auto_update_check_enabled: false,
229            last_update_check_at_unix: None,
230            show_first_run_update_prompt: true,
231            date_logic_version: 0,
232            geonames_warning_seen: false,
233            thumbnail_cache_gb: default_thumbnail_cache_gb(),
234            config_version: 4,
235            face_embedder_model: default_face_embedder_model(),
236            assistant_enabled: false,
237            ai_features_enabled: false,
238            assistant_provider: default_assistant_provider(),
239            assistant_base_url: default_assistant_base_url(),
240            assistant_model: default_assistant_model(),
241            assistant_api_key: None,
242            face_gpu_bridge_url: None,
243            face_gpu_bridge_enabled: false,
244        }
245    }
246}
247
248impl AppConfig {
249    /// Load config from disk. Falls back to defaults on any failure.
250    pub fn load() -> Self {
251        let path = Self::config_path();
252        // If the new path is empty but a pre-rename config exists,
253        // pick that up once so upgrading users don't lose their settings.
254        let read_path = if path.exists() {
255            path
256        } else {
257            let legacy = Self::legacy_config_path();
258            if legacy.exists() {
259                legacy
260            } else {
261                return Self::default();
262            }
263        };
264
265        match std::fs::read_to_string(&read_path) {
266            Ok(content) => match serde_json::from_str::<Self>(&content) {
267                Ok(mut cfg) => {
268                    cfg.validate();
269                    cfg.migrate_config_defaults();
270                    cfg
271                }
272                Err(e) => {
273                    tracing::warn!("Failed to parse config {}: {}", read_path.display(), e);
274                    Self::default()
275                }
276            },
277            Err(e) => {
278                tracing::warn!("Failed to read config {}: {}", read_path.display(), e);
279                Self::default()
280            }
281        }
282    }
283
284    /// Apply one-time config migrations when defaults change.
285    /// Bumps `config_version` after each migration so it doesn't re-run.
286    fn migrate_config_defaults(&mut self) {
287        if self.config_version < 1 {
288            // v1: tightened clustering threshold (0.42 → 0.28).
289            // Only bump if the user never changed the old default.
290            if (self.face_clustering_threshold - 0.42).abs() < 0.001 {
291                self.face_clustering_threshold = 0.28;
292            }
293            self.config_version = 1;
294        }
295        if self.config_version < 2 {
296            // v2: bump to 0.30 for AdaFace (tighter centroids).
297            if (self.face_clustering_threshold - 0.28).abs() < 0.001 {
298                self.face_clustering_threshold = 0.30;
299            }
300            self.config_version = 2;
301        }
302        if self.config_version < 3 {
303            self.face_embedder_model = default_face_embedder_model();
304            self.config_version = 3;
305        }
306        if self.config_version < 4 {
307            if self.assistant_provider == "local" {
308                self.assistant_provider = default_assistant_provider();
309            }
310            if self.assistant_base_url == "https://api.openai.com/v1" {
311                self.assistant_base_url = default_assistant_base_url();
312            }
313            if self.assistant_model == "gpt-4.1-mini" {
314                self.assistant_model = default_assistant_model();
315            }
316            self.config_version = 4;
317        }
318    }
319
320    /// Clamp all values to valid ranges.
321    pub fn validate(&mut self) {
322        // Sanity-check GPU bridge URL if set. Plain HTTP is allowed
323        // only for local notebooks / tunnels bound to loopback.
324        if let Some(ref url) = self.face_gpu_bridge_url {
325            if !is_allowed_gpu_bridge_url(url) {
326                tracing::warn!(
327                    "Invalid face_gpu_bridge_url '{}' — must use https:// or local http://. Disabling.",
328                    url
329                );
330                self.face_gpu_bridge_url = None;
331                self.face_gpu_bridge_enabled = false;
332            }
333        }
334
335        if !self.face_detection_confidence.is_finite() {
336            self.face_detection_confidence = Self::default().face_detection_confidence;
337        }
338        if !self.face_clustering_threshold.is_finite() {
339            self.face_clustering_threshold = Self::default().face_clustering_threshold;
340        }
341        if !self.weight_cooccurrence.is_finite() {
342            self.weight_cooccurrence = default_weight_cooccurrence();
343        }
344        if !self.weight_temporal.is_finite() {
345            self.weight_temporal = default_weight_temporal();
346        }
347        if !self.thumbnail_cache_gb.is_finite() {
348            self.thumbnail_cache_gb = default_thumbnail_cache_gb();
349        }
350
351        self.face_detection_confidence = self.face_detection_confidence.clamp(0.1, 0.95);
352        self.face_clustering_threshold = self.face_clustering_threshold.clamp(0.1, 0.8);
353        self.thumbnail_size = self.thumbnail_size.clamp(100, 1000);
354        self.burst_time_window_seconds = self.burst_time_window_seconds.clamp(1, 30);
355        self.trash_auto_delete_days = self.trash_auto_delete_days.clamp(1, 365);
356        self.window_width = self.window_width.clamp(400, 7680);
357        self.window_height = self.window_height.clamp(300, 4320);
358        self.weight_cooccurrence = self.weight_cooccurrence.clamp(0.0, 2.0);
359        self.weight_temporal = self.weight_temporal.clamp(0.0, 2.0);
360        self.map_cache_limit_mb = self.map_cache_limit_mb.clamp(50, 10_000);
361        self.thumbnail_cache_gb = self.thumbnail_cache_gb.clamp(0.5, 100.0);
362        if self.face_embedder_model != default_face_embedder_model() {
363            self.face_embedder_model = default_face_embedder_model();
364        }
365        if !matches!(
366            self.assistant_provider.as_str(),
367            "local" | "openai_compatible"
368        ) {
369            self.assistant_provider = default_assistant_provider();
370        }
371        if !self.assistant_base_url.starts_with("https://")
372            && !self.assistant_base_url.starts_with("http://localhost")
373            && !self.assistant_base_url.starts_with("http://127.0.0.1")
374        {
375            self.assistant_base_url = default_assistant_base_url();
376        }
377        if self.assistant_model.trim().is_empty() {
378            self.assistant_model = default_assistant_model();
379        }
380        if self
381            .assistant_api_key
382            .as_deref()
383            .is_some_and(|s| s.trim().is_empty())
384        {
385            self.assistant_api_key = None;
386        }
387    }
388
389    /// Save config to disk.
390    pub fn save(&self) -> std::io::Result<()> {
391        let path = Self::config_path();
392        if let Some(parent) = path.parent() {
393            std::fs::create_dir_all(parent)?;
394        }
395        let content = serde_json::to_string_pretty(self)?;
396        std::fs::write(path, content)
397    }
398
399    /// Config file path.
400    pub fn config_path() -> PathBuf {
401        dirs::config_dir()
402            .unwrap_or_else(|| PathBuf::from("."))
403            .join("smriti")
404            .join("config.json")
405    }
406
407    /// Pre-rename config path. Read-only fallback so users upgrading
408    /// from PhotoVault keep their settings on first launch — once we
409    /// save, the new path takes over.
410    fn legacy_config_path() -> PathBuf {
411        dirs::config_dir()
412            .unwrap_or_else(|| PathBuf::from("."))
413            .join("photovault")
414            .join("config.json")
415    }
416
417    /// Add drive to remembered list.
418    pub fn remember_drive(&mut self, path: PathBuf) {
419        if !self.remembered_drives.contains(&path) {
420            self.remembered_drives.push(path);
421        }
422    }
423}
424
425pub fn is_allowed_gpu_bridge_url(url: &str) -> bool {
426    if url.starts_with("https://") {
427        return true;
428    }
429    let Some(rest) = url.strip_prefix("http://") else {
430        return false;
431    };
432    let host_port = rest.split('/').next().unwrap_or_default();
433    let host = host_port
434        .strip_prefix('[')
435        .and_then(|s| s.split(']').next())
436        .unwrap_or_else(|| host_port.split(':').next().unwrap_or_default());
437
438    matches!(host, "localhost" | "127.0.0.1" | "::1") || host.starts_with("127.")
439}
440
441/// Theme setting.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "lowercase")]
444pub enum AppTheme {
445    Dark,
446    Light,
447    System,
448}
449
450/// Date format setting.
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "lowercase")]
453pub enum DateFormat {
454    Locale,
455    Iso,
456    Us,
457    Eu,
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn validate_forces_adaface_embedder() {
466        let mut cfg = AppConfig {
467            face_embedder_model: "legacy.onnx".to_string(),
468            ..Default::default()
469        };
470
471        cfg.validate();
472
473        assert_eq!(cfg.face_embedder_model, default_face_embedder_model());
474    }
475
476    #[test]
477    fn validate_replaces_non_finite_numeric_settings() {
478        let mut cfg = AppConfig {
479            face_detection_confidence: f32::NAN,
480            face_clustering_threshold: f32::INFINITY,
481            weight_cooccurrence: f32::NEG_INFINITY,
482            weight_temporal: f32::NAN,
483            thumbnail_cache_gb: f64::NAN,
484            ..Default::default()
485        };
486
487        cfg.validate();
488
489        assert_eq!(
490            cfg.face_detection_confidence,
491            AppConfig::default().face_detection_confidence
492        );
493        assert_eq!(
494            cfg.face_clustering_threshold,
495            AppConfig::default().face_clustering_threshold
496        );
497        assert_eq!(cfg.weight_cooccurrence, default_weight_cooccurrence());
498        assert_eq!(cfg.weight_temporal, default_weight_temporal());
499        assert_eq!(cfg.thumbnail_cache_gb, default_thumbnail_cache_gb());
500    }
501
502    #[test]
503    fn config_migration_forces_adaface_embedder() {
504        let mut cfg = AppConfig {
505            config_version: 2,
506            face_embedder_model: "legacy.onnx".to_string(),
507            ..Default::default()
508        };
509
510        cfg.migrate_config_defaults();
511
512        assert_eq!(cfg.config_version, 4);
513        assert_eq!(cfg.face_embedder_model, default_face_embedder_model());
514    }
515}