Skip to main content

entracte_lib/
config.rs

1#[cfg(test)]
2use std::fs;
3use std::io;
4use std::path::Path;
5
6use log::warn;
7use serde::de::{self, Deserializer, MapAccess, Visitor};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::scheduler::Settings;
12use crate::secure_io::write_user_only;
13
14pub const DEFAULT_PROFILE_NAME: &str = "Default";
15
16pub fn migrate_legacy_settings(value: &mut Value) {
17    let Some(obj) = value.as_object_mut() else {
18        return;
19    };
20    if obj.contains_key("monitor_placement") {
21        obj.remove("cover_all_monitors");
22    } else if let Some(raw) = obj.remove("cover_all_monitors") {
23        let placement = match raw.as_bool() {
24            Some(true) => "all",
25            _ => "primary",
26        };
27        obj.insert(
28            "monitor_placement".to_string(),
29            Value::String(placement.to_string()),
30        );
31    }
32    migrate_sound_fields(obj);
33}
34
35// Map legacy global `sound_theme` to per-kind `micro_sound` + `long_sound`.
36// Only fires when neither per-kind field is present, so user-set new values are never clobbered.
37// Also strips obsolete fields so they don't leak into the deserialized Settings via flatten.
38fn migrate_sound_fields(obj: &mut serde_json::Map<String, Value>) {
39    if !obj.contains_key("micro_sound") && !obj.contains_key("long_sound") {
40        let theme = obj
41            .get("sound_theme")
42            .and_then(|v| v.as_str())
43            .unwrap_or_default();
44        let (mode, sound_id) = match theme {
45            "silence" => ("off", ""),
46            "soft_chime" => ("end_chime", "337048"),
47            "bright_bell" => ("end_chime", "398496"),
48            "wood_block" => ("end_chime", "445633"),
49            _ => ("end_chime", "337048"),
50        };
51        let value = serde_json::json!({ "mode": mode, "sound_id": sound_id });
52        obj.insert("micro_sound".to_string(), value.clone());
53        obj.insert("long_sound".to_string(), value);
54    }
55    obj.remove("sound_theme");
56    obj.remove("sound_mode");
57    obj.remove("sound_end_chime");
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct Profile {
62    pub name: String,
63    pub settings: Settings,
64}
65
66impl<'de> Deserialize<'de> for Profile {
67    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
68    where
69        D: Deserializer<'de>,
70    {
71        #[derive(Deserialize)]
72        struct Raw {
73            name: String,
74            settings: Value,
75        }
76        let Raw { name, mut settings } = Raw::deserialize(deserializer)?;
77        migrate_legacy_settings(&mut settings);
78        let mut settings: Settings = serde_json::from_value(settings).map_err(de::Error::custom)?;
79        settings.clamp();
80        Ok(Profile { name, settings })
81    }
82}
83
84#[derive(Debug, Clone, Serialize)]
85pub struct ProfilesFile {
86    pub profiles: Vec<Profile>,
87    pub active: String,
88    /// Whether the first-run onboarding wizard has been completed (or
89    /// skipped). App-global rather than per-profile: onboarding is about
90    /// the install, not a single profile. A fresh install starts `false`
91    /// so the wizard shows once; any file that already exists on disk —
92    /// including one upgraded from a version that predates this field —
93    /// loads as `true` so existing users are never re-onboarded.
94    pub onboarding_completed: bool,
95}
96
97impl Default for ProfilesFile {
98    fn default() -> Self {
99        // Reached when no settings file exists yet (genuine first run), so
100        // onboarding has not been completed.
101        Self {
102            profiles: vec![Profile {
103                name: DEFAULT_PROFILE_NAME.to_string(),
104                settings: Settings::default(),
105            }],
106            active: DEFAULT_PROFILE_NAME.to_string(),
107            onboarding_completed: false,
108        }
109    }
110}
111
112impl ProfilesFile {
113    /// Wrap a single profile. Used when migrating an existing flat/legacy
114    /// settings file, so `onboarding_completed` is `true`: the user already
115    /// has settings on disk and should not be onboarded.
116    pub fn single(name: String, settings: Settings) -> Self {
117        Self {
118            profiles: vec![Profile {
119                name: name.clone(),
120                settings,
121            }],
122            active: name,
123            onboarding_completed: true,
124        }
125    }
126
127    pub fn active_settings(&self) -> Settings {
128        let mut settings = self
129            .profiles
130            .iter()
131            .find(|p| p.name == self.active)
132            .map(|p| p.settings.clone())
133            .or_else(|| self.profiles.first().map(|p| p.settings.clone()))
134            .unwrap_or_default();
135        // Profiles are deserialised, so each one's `#[serde(skip)]`
136        // `derived` cache arrives empty. Rebuild it here so every caller
137        // (scheduler construction, backup import) gets a populated cache.
138        settings.rebuild_derived();
139        settings
140    }
141}
142
143impl<'de> Deserialize<'de> for ProfilesFile {
144    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
145    where
146        D: Deserializer<'de>,
147    {
148        struct PFVisitor;
149
150        impl<'de> Visitor<'de> for PFVisitor {
151            type Value = ProfilesFile;
152
153            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
154                f.write_str("a profiles file or a legacy settings object")
155            }
156
157            fn visit_map<M>(self, mut map: M) -> Result<ProfilesFile, M::Error>
158            where
159                M: MapAccess<'de>,
160            {
161                let mut raw: serde_json::Map<String, Value> = serde_json::Map::new();
162                while let Some(key) = map.next_key::<String>()? {
163                    let val: Value = map.next_value()?;
164                    raw.insert(key, val);
165                }
166                let raw_value = Value::Object(raw);
167                let has_profiles = raw_value.get("profiles").is_some();
168                if has_profiles {
169                    let profiles: Vec<Profile> = serde_json::from_value(
170                        raw_value.get("profiles").cloned().unwrap_or(Value::Null),
171                    )
172                    .map_err(de::Error::custom)?;
173                    let active = raw_value
174                        .get("active")
175                        .and_then(|v| v.as_str())
176                        .map(String::from)
177                        .or_else(|| profiles.first().map(|p| p.name.clone()))
178                        .unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_string());
179                    // A file already on disk means the user has used the app
180                    // before; default to onboarded when the field is absent
181                    // (file written before onboarding existed).
182                    let onboarding_completed = raw_value
183                        .get("onboarding_completed")
184                        .and_then(|v| v.as_bool())
185                        .unwrap_or(true);
186                    Ok(ProfilesFile {
187                        profiles,
188                        active,
189                        onboarding_completed,
190                    })
191                } else {
192                    let mut raw_value = raw_value;
193                    migrate_legacy_settings(&mut raw_value);
194                    let mut settings: Settings =
195                        serde_json::from_value(raw_value).map_err(de::Error::custom)?;
196                    settings.clamp();
197                    Ok(ProfilesFile::single(
198                        DEFAULT_PROFILE_NAME.to_string(),
199                        settings,
200                    ))
201                }
202            }
203        }
204
205        deserializer.deserialize_map(PFVisitor)
206    }
207}
208
209/// Refuse to load a settings file larger than 4 MiB. Real settings
210/// (hooks + custom CSS + 5 profiles) come in well under 100 KiB; the
211/// cap defends against a maliciously-grown JSON file causing the
212/// loader to allocate gigabytes.
213const MAX_CONFIG_BYTES: u64 = 4 * 1024 * 1024;
214
215pub fn load(path: &Path) -> ProfilesFile {
216    match crate::secure_io::read_capped(path, MAX_CONFIG_BYTES) {
217        Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
218            warn!(
219                "config: failed to parse {}: {e} — using defaults",
220                path.display()
221            );
222            ProfilesFile::default()
223        }),
224        Err(e) if e.kind() == io::ErrorKind::NotFound => ProfilesFile::default(),
225        Err(e) => {
226            warn!(
227                "config: failed to read {}: {e} — using defaults",
228                path.display()
229            );
230            ProfilesFile::default()
231        }
232    }
233}
234
235pub fn save(path: &Path, file: &ProfilesFile) -> io::Result<()> {
236    let body = serde_json::to_string_pretty(file).map_err(io::Error::other)?;
237    write_user_only(path, body.as_bytes())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::test_support::{temp_dir, TempDir};
244
245    fn temp_file() -> (TempDir, std::path::PathBuf) {
246        let dir = temp_dir();
247        let path = dir.path().join("settings.json");
248        (dir, path)
249    }
250
251    #[test]
252    fn load_missing_returns_default_profile() {
253        let dir = temp_dir();
254        let path = dir.path().join("does-not-exist.json");
255        let f = load(&path);
256        assert_eq!(f.profiles.len(), 1);
257        assert_eq!(f.active, DEFAULT_PROFILE_NAME);
258        assert_eq!(f.profiles[0].name, DEFAULT_PROFILE_NAME);
259        let d = Settings::default();
260        assert_eq!(
261            f.profiles[0].settings.micro_interval_secs,
262            d.micro_interval_secs
263        );
264    }
265
266    #[test]
267    #[allow(clippy::field_reassign_with_default)]
268    fn save_and_load_round_trip_multiple_profiles() {
269        let (_dir, path) = temp_file();
270        let mut work = Settings::default();
271        work.micro_interval_secs = 600;
272        work.overlay_color = "forest".to_string();
273        let mut home = Settings::default();
274        home.micro_interval_secs = 1800;
275        home.overlay_color = "rose".to_string();
276
277        let file = ProfilesFile {
278            profiles: vec![
279                Profile {
280                    name: "Work".to_string(),
281                    settings: work,
282                },
283                Profile {
284                    name: "Home".to_string(),
285                    settings: home,
286                },
287            ],
288            active: "Home".to_string(),
289            ..ProfilesFile::default()
290        };
291        save(&path, &file).unwrap();
292        let loaded = load(&path);
293        assert_eq!(loaded.profiles.len(), 2);
294        assert_eq!(loaded.active, "Home");
295        assert_eq!(loaded.profiles[0].name, "Work");
296        assert_eq!(loaded.profiles[0].settings.micro_interval_secs, 600);
297        assert_eq!(loaded.profiles[1].name, "Home");
298        assert_eq!(loaded.profiles[1].settings.overlay_color, "rose");
299    }
300
301    #[test]
302    fn load_legacy_flat_settings_wraps_into_default_profile() {
303        let (_dir, path) = temp_file();
304        fs::write(
305            &path,
306            r#"{"micro_interval_secs": 99, "overlay_color": "rose"}"#,
307        )
308        .unwrap();
309        let loaded = load(&path);
310        assert_eq!(loaded.profiles.len(), 1);
311        assert_eq!(loaded.active, DEFAULT_PROFILE_NAME);
312        assert_eq!(loaded.profiles[0].name, DEFAULT_PROFILE_NAME);
313        assert_eq!(loaded.profiles[0].settings.micro_interval_secs, 99);
314        assert_eq!(loaded.profiles[0].settings.overlay_color, "rose");
315        let d = Settings::default();
316        assert_eq!(
317            loaded.profiles[0].settings.long_interval_secs,
318            d.long_interval_secs
319        );
320    }
321
322    #[test]
323    fn load_legacy_then_save_persists_wrapped_shape() {
324        let (_dir, path) = temp_file();
325        fs::write(
326            &path,
327            r#"{"micro_interval_secs": 77, "overlay_color": "midnight"}"#,
328        )
329        .unwrap();
330        let loaded = load(&path);
331        save(&path, &loaded).unwrap();
332        let text = fs::read_to_string(&path).unwrap();
333        assert!(text.contains("\"profiles\""));
334        assert!(text.contains("\"active\""));
335        let reloaded = load(&path);
336        assert_eq!(reloaded.profiles.len(), 1);
337        assert_eq!(reloaded.profiles[0].settings.micro_interval_secs, 77);
338    }
339
340    #[test]
341    fn load_corrupt_returns_default() {
342        let (_dir, path) = temp_file();
343        fs::write(&path, "{not valid json").unwrap();
344        let loaded = load(&path);
345        assert_eq!(loaded.profiles.len(), 1);
346        assert_eq!(loaded.active, DEFAULT_PROFILE_NAME);
347    }
348
349    #[test]
350    fn load_too_large_file_returns_default() {
351        // Exceeds MAX_CONFIG_BYTES (4 MiB). `read_capped` returns
352        // ErrorKind::InvalidData, falling through to the
353        // not-NotFound arm in `load()`.
354        let (_dir, path) = temp_file();
355        let blob = "x".repeat(5 * 1024 * 1024);
356        fs::write(&path, &blob).unwrap();
357        let loaded = load(&path);
358        assert_eq!(loaded.profiles.len(), 1);
359        assert_eq!(loaded.active, DEFAULT_PROFILE_NAME);
360    }
361
362    #[test]
363    fn save_creates_parent_dirs() {
364        let dir = temp_dir();
365        let path = dir.path().join("a").join("b").join("settings.json");
366        save(&path, &ProfilesFile::default()).unwrap();
367        assert!(path.exists());
368    }
369
370    #[test]
371    fn migrate_legacy_settings_true_becomes_all() {
372        let mut v: Value = serde_json::from_str(r#"{"cover_all_monitors": true}"#).unwrap();
373        migrate_legacy_settings(&mut v);
374        assert_eq!(
375            v.get("monitor_placement").and_then(|x| x.as_str()),
376            Some("all")
377        );
378        assert!(v.get("cover_all_monitors").is_none());
379    }
380
381    #[test]
382    fn migrate_legacy_settings_false_becomes_primary() {
383        let mut v: Value = serde_json::from_str(r#"{"cover_all_monitors": false}"#).unwrap();
384        migrate_legacy_settings(&mut v);
385        assert_eq!(
386            v.get("monitor_placement").and_then(|x| x.as_str()),
387            Some("primary")
388        );
389        assert!(v.get("cover_all_monitors").is_none());
390    }
391
392    #[test]
393    fn migrate_legacy_settings_preserves_existing_placement() {
394        let mut v: Value =
395            serde_json::from_str(r#"{"cover_all_monitors": true, "monitor_placement": "active"}"#)
396                .unwrap();
397        migrate_legacy_settings(&mut v);
398        assert_eq!(
399            v.get("monitor_placement").and_then(|x| x.as_str()),
400            Some("active")
401        );
402        assert!(v.get("cover_all_monitors").is_none());
403    }
404
405    #[test]
406    fn migrate_legacy_settings_no_op_when_neither_present() {
407        let mut v: Value = serde_json::from_str(r#"{"micro_interval_secs": 60}"#).unwrap();
408        migrate_legacy_settings(&mut v);
409        assert!(v.get("monitor_placement").is_none());
410    }
411
412    #[test]
413    fn load_legacy_cover_all_monitors_true_migrates_to_all() {
414        let (_dir, path) = temp_file();
415        fs::write(&path, r#"{"cover_all_monitors": true}"#).unwrap();
416        let loaded = load(&path);
417        assert_eq!(loaded.profiles.len(), 1);
418        assert!(matches!(
419            loaded.profiles[0].settings.monitor_placement,
420            crate::scheduler::MonitorPlacement::All
421        ));
422    }
423
424    #[test]
425    fn load_legacy_cover_all_monitors_false_migrates_to_primary() {
426        let (_dir, path) = temp_file();
427        fs::write(&path, r#"{"cover_all_monitors": false}"#).unwrap();
428        let loaded = load(&path);
429        assert!(matches!(
430            loaded.profiles[0].settings.monitor_placement,
431            crate::scheduler::MonitorPlacement::Primary
432        ));
433    }
434
435    #[test]
436    fn load_profiles_with_legacy_cover_all_monitors_migrates() {
437        let (_dir, path) = temp_file();
438        fs::write(
439            &path,
440            r#"{"profiles":[{"name":"Work","settings":{"cover_all_monitors":true}},{"name":"Home","settings":{"cover_all_monitors":false}}],"active":"Work"}"#,
441        )
442        .unwrap();
443        let loaded = load(&path);
444        assert!(matches!(
445            loaded.profiles[0].settings.monitor_placement,
446            crate::scheduler::MonitorPlacement::All
447        ));
448        assert!(matches!(
449            loaded.profiles[1].settings.monitor_placement,
450            crate::scheduler::MonitorPlacement::Primary
451        ));
452    }
453
454    #[test]
455    fn legacy_sound_theme_migrates_to_per_kind_break_sound() {
456        let cases = [
457            ("silence", "off", ""),
458            ("soft_chime", "end_chime", "337048"),
459            ("bright_bell", "end_chime", "398496"),
460            ("wood_block", "end_chime", "445633"),
461            ("garbage_value", "end_chime", "337048"),
462        ];
463        for (theme, want_mode, want_id) in cases {
464            let (_dir, path) = temp_file();
465            fs::write(
466                &path,
467                format!(r#"{{"sound_theme": "{theme}", "sound_volume": 0.4}}"#),
468            )
469            .unwrap();
470            let loaded = load(&path);
471            let s = &loaded.profiles[0].settings;
472            assert_eq!(
473                serde_json::to_string(&s.micro_sound.mode).unwrap(),
474                format!("\"{want_mode}\""),
475                "micro mode mismatch for theme {theme}"
476            );
477            assert_eq!(
478                s.micro_sound.sound_id, want_id,
479                "micro id mismatch for theme {theme}"
480            );
481            assert_eq!(
482                s.long_sound.mode, s.micro_sound.mode,
483                "long should match micro for theme {theme}"
484            );
485            assert_eq!(s.long_sound.sound_id, want_id);
486        }
487    }
488
489    #[test]
490    fn explicit_per_kind_sound_is_not_overwritten_by_migration() {
491        let (_dir, path) = temp_file();
492        fs::write(
493            &path,
494            r#"{
495                "sound_theme": "soft_chime",
496                "micro_sound": {"mode": "ambient", "sound_id": "851196"}
497            }"#,
498        )
499        .unwrap();
500        let loaded = load(&path);
501        let s = &loaded.profiles[0].settings;
502        assert_eq!(
503            serde_json::to_string(&s.micro_sound.mode).unwrap(),
504            "\"ambient\"",
505            "explicit micro_sound must win over legacy theme"
506        );
507        assert_eq!(s.micro_sound.sound_id, "851196");
508    }
509
510    #[test]
511    fn legacy_sound_theme_migrates_inside_profiles_file() {
512        let (_dir, path) = temp_file();
513        fs::write(
514            &path,
515            r#"{
516                "profiles": [
517                    {"name": "A", "settings": {"sound_theme": "bright_bell"}},
518                    {"name": "B", "settings": {"sound_theme": "silence"}}
519                ],
520                "active": "B"
521            }"#,
522        )
523        .unwrap();
524        let loaded = load(&path);
525        assert_eq!(loaded.profiles.len(), 2);
526        assert_eq!(loaded.profiles[0].settings.micro_sound.sound_id, "398496");
527        assert_eq!(loaded.profiles[0].settings.long_sound.sound_id, "398496");
528        assert_eq!(
529            serde_json::to_string(&loaded.profiles[1].settings.micro_sound.mode).unwrap(),
530            "\"off\""
531        );
532        assert_eq!(loaded.profiles[1].settings.micro_sound.sound_id, "");
533    }
534
535    #[test]
536    fn legacy_sound_mode_and_end_chime_are_stripped() {
537        // Users on the prior WIP build had `sound_mode` + `sound_end_chime`.
538        // Migration discards both — Settings no longer carries those fields,
539        // and the new per-kind config takes the legacy theme's defaults instead.
540        let (_dir, path) = temp_file();
541        fs::write(
542            &path,
543            r#"{"sound_mode": "end_chime", "sound_end_chime": ["398496"], "sound_theme": "bright_bell"}"#,
544        )
545        .unwrap();
546        let loaded = load(&path);
547        let s = &loaded.profiles[0].settings;
548        assert_eq!(s.micro_sound.sound_id, "398496");
549        assert_eq!(s.long_sound.sound_id, "398496");
550    }
551
552    #[test]
553    fn load_missing_marks_onboarding_incomplete() {
554        let dir = temp_dir();
555        let path = dir.path().join("does-not-exist.json");
556        assert!(
557            !load(&path).onboarding_completed,
558            "a genuine first run must show onboarding"
559        );
560    }
561
562    #[test]
563    fn load_legacy_flat_settings_marks_onboarding_complete() {
564        let (_dir, path) = temp_file();
565        fs::write(&path, r#"{"micro_interval_secs": 99}"#).unwrap();
566        assert!(
567            load(&path).onboarding_completed,
568            "an existing legacy file must not re-onboard the user"
569        );
570    }
571
572    #[test]
573    fn load_profiles_file_without_flag_marks_onboarding_complete() {
574        let (_dir, path) = temp_file();
575        fs::write(
576            &path,
577            r#"{"profiles":[{"name":"Default","settings":{}}],"active":"Default"}"#,
578        )
579        .unwrap();
580        assert!(
581            load(&path).onboarding_completed,
582            "a pre-onboarding profiles file must not re-onboard the user"
583        );
584    }
585
586    #[test]
587    fn load_profiles_file_honours_explicit_flag() {
588        let (_dir, path) = temp_file();
589        fs::write(
590            &path,
591            r#"{"profiles":[{"name":"Default","settings":{}}],"active":"Default","onboarding_completed":false}"#,
592        )
593        .unwrap();
594        assert!(
595            !load(&path).onboarding_completed,
596            "an explicit false must be honoured (onboarding still pending)"
597        );
598    }
599
600    #[test]
601    fn onboarding_flag_round_trips_through_save() {
602        let (_dir, path) = temp_file();
603        let mut file = ProfilesFile::default();
604        assert!(!file.onboarding_completed);
605        file.onboarding_completed = true;
606        save(&path, &file).unwrap();
607        assert!(load(&path).onboarding_completed);
608
609        let text = fs::read_to_string(&path).unwrap();
610        assert!(text.contains("\"onboarding_completed\""));
611    }
612
613    #[test]
614    fn active_settings_falls_back_to_first_when_missing() {
615        let file = ProfilesFile {
616            profiles: vec![Profile {
617                name: "Only".to_string(),
618                settings: Settings::default(),
619            }],
620            active: "Missing".to_string(),
621            ..ProfilesFile::default()
622        };
623        let s = file.active_settings();
624        assert_eq!(
625            s.micro_interval_secs,
626            Settings::default().micro_interval_secs
627        );
628    }
629}