Skip to main content

screen_app/
recorder_settings.rs

1//! Persistent recorder preferences (M-SAVE.0) — JSON at
2//! `<app-config-dir>/recorder-settings.json`.
3//!
4//! - **`output_dir`** — the directory new recordings export into.
5//!   `None` → the per-OS default
6//!   ([`recording_paths::default_output_dir`](crate::recording_paths::default_output_dir)).
7//! - **`last_format`** — the export format slug last chosen in the Save
8//!   panel (`"mp4-h264"` / `"webm-vp9"`). `None` → the default format.
9//!
10//! Both fields are `#[serde(default)]` + `Option`, so partial / older
11//! files still load (missing keys → `None`).
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use tauri::Manager;
17
18/// File name under the app-config dir. See module docs.
19const SETTINGS_FILE: &str = "recorder-settings.json";
20
21/// Cross-session recorder preferences. See module docs for the
22/// per-field semantics.
23#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct RecorderSettings {
25    /// User-chosen export directory. `None` → per-OS default.
26    #[serde(default)]
27    pub output_dir: Option<PathBuf>,
28    /// Last-used export format slug. `None` → default format.
29    #[serde(default)]
30    pub last_format: Option<String>,
31}
32
33/// Parse settings from the JSON at `path`, falling back to
34/// [`RecorderSettings::default`] on any failure (missing / unreadable
35/// / malformed) — a corrupt file degrades to defaults, never blocks.
36#[must_use]
37pub fn load_from(path: &Path) -> RecorderSettings {
38    let Ok(raw) = std::fs::read_to_string(path) else {
39        return RecorderSettings::default();
40    };
41    serde_json::from_str(&raw).unwrap_or_default()
42}
43
44/// Serialize `settings` to the JSON at `path`, creating the parent
45/// directory if needed.
46///
47/// # Errors
48///
49/// [`std::io::Error`] if the parent dir can't be created or the write
50/// fails (a serialize error is surfaced as `InvalidData`).
51pub fn save_to(path: &Path, settings: &RecorderSettings) -> std::io::Result<()> {
52    if let Some(parent) = path.parent() {
53        std::fs::create_dir_all(parent)?;
54    }
55    let json = serde_json::to_string_pretty(settings)
56        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
57    std::fs::write(path, json)
58}
59
60/// Resolve the settings-file path under Tauri's app-config dir.
61/// `None` if the platform path resolver fails (extremely rare —
62/// missing `$HOME` with no fallback).
63fn settings_path(app: &tauri::AppHandle) -> Option<PathBuf> {
64    Some(app.path().app_config_dir().ok()?.join(SETTINGS_FILE))
65}
66
67/// Load the persisted settings for this app handle. Defaults on any
68/// failure (see [`load_from`]).
69#[must_use]
70pub fn load(app: &tauri::AppHandle) -> RecorderSettings {
71    settings_path(app)
72        .map(|p| load_from(&p))
73        .unwrap_or_default()
74}
75
76/// Persist `settings` for this app handle.
77///
78/// # Errors
79///
80/// Returns an error string when the app-config dir is unavailable or
81/// the write fails (see [`save_to`]).
82pub fn save(app: &tauri::AppHandle, settings: &RecorderSettings) -> Result<(), String> {
83    let path = settings_path(app).ok_or("app config dir unavailable")?;
84    save_to(&path, settings).map_err(|err| format!("failed to write recorder settings: {err}"))
85}
86
87/// The directory new recordings should export into: the persisted
88/// override if set, otherwise the per-OS default. This is the single
89/// resolver every output-path computation should call so the chosen
90/// directory is honored everywhere.
91#[must_use]
92pub fn resolved_output_dir(app: &tauri::AppHandle) -> PathBuf {
93    load(app)
94        .output_dir
95        .unwrap_or_else(crate::recording_paths::default_output_dir)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn temp_path(tag: &str) -> PathBuf {
103        std::env::temp_dir().join(format!("m-save-settings-{tag}.json"))
104    }
105
106    #[test]
107    fn load_from_missing_file_is_default() {
108        let path = temp_path("missing-never-created");
109        let _ = std::fs::remove_file(&path);
110        assert_eq!(load_from(&path), RecorderSettings::default());
111    }
112
113    #[test]
114    fn load_from_malformed_json_is_default() {
115        let path = temp_path("malformed");
116        std::fs::write(&path, "{ this is not json").expect("write");
117        assert_eq!(load_from(&path), RecorderSettings::default());
118        let _ = std::fs::remove_file(&path);
119    }
120
121    #[test]
122    fn save_then_load_round_trips() {
123        let path = temp_path("round-trip");
124        let _ = std::fs::remove_file(&path);
125        let settings = RecorderSettings {
126            output_dir: Some(PathBuf::from("/Users/x/Desktop/My Recordings")),
127            last_format: Some("webm-vp9".to_owned()),
128        };
129        save_to(&path, &settings).expect("save");
130        assert_eq!(load_from(&path), settings);
131        let _ = std::fs::remove_file(&path);
132    }
133
134    #[test]
135    fn save_creates_parent_dir() {
136        let dir = std::env::temp_dir().join("m-save-settings-nested-parent");
137        let _ = std::fs::remove_dir_all(&dir);
138        let path = dir.join("sub").join(SETTINGS_FILE);
139        save_to(&path, &RecorderSettings::default()).expect("save into nested dir");
140        assert!(path.exists());
141        let _ = std::fs::remove_dir_all(&dir);
142    }
143
144    #[test]
145    fn path_with_special_chars_survives_round_trip() {
146        // A path with a comma + colon would break a naive key:value
147        // text format; JSON escapes it. This is why the module uses
148        // serde_json rather than the bubble-position text scheme.
149        let path = temp_path("special-chars");
150        let _ = std::fs::remove_file(&path);
151        let settings = RecorderSettings {
152            output_dir: Some(PathBuf::from("/tmp/odd, name: with chars")),
153            last_format: None,
154        };
155        save_to(&path, &settings).expect("save");
156        assert_eq!(load_from(&path), settings);
157        let _ = std::fs::remove_file(&path);
158    }
159
160    #[test]
161    fn partial_json_fills_missing_field_with_none() {
162        // A file written by an older build that only knew `output_dir`
163        // must still parse, leaving `last_format` as None.
164        let path = temp_path("partial");
165        std::fs::write(&path, r#"{"output_dir":"/tmp/only-dir"}"#).expect("write");
166        let loaded = load_from(&path);
167        assert_eq!(loaded.output_dir, Some(PathBuf::from("/tmp/only-dir")));
168        assert_eq!(loaded.last_format, None);
169        let _ = std::fs::remove_file(&path);
170    }
171
172    #[test]
173    fn default_has_no_overrides() {
174        let d = RecorderSettings::default();
175        assert!(d.output_dir.is_none());
176        assert!(d.last_format.is_none());
177    }
178}