Skip to main content

entracte_lib/scheduler/commands/
settings.rs

1use crate::config;
2use crate::supporter;
3use crate::SupporterAppState;
4
5use super::super::settings::Settings;
6use super::super::Scheduler;
7
8/// Return a clone of the active profile's `Settings`. The renderer
9/// calls this on mount and again whenever the active profile changes.
10///
11/// `custom_css` is blanked for non-supporters so the renderer can't
12/// apply (or even read back) a stylesheet they aren't licensed for.
13/// The on-disk value is preserved — re-activating the license restores it.
14#[tauri::command]
15pub async fn get_settings(
16    scheduler: tauri::State<'_, Scheduler>,
17    supporter_state: tauri::State<'_, SupporterAppState>,
18) -> Result<Settings, String> {
19    let mut s = scheduler.settings.lock().await.clone();
20    if !supporter::is_supporter_now(&supporter_state.path) {
21        s.custom_css = String::new();
22    }
23    Ok(s)
24}
25
26/// Replace the active profile's settings with `new` and persist.
27///
28/// Hook fields are stripped from the payload before merge (see
29/// `strip_hooks`) — hooks must go through `set_hooks` so the user
30/// confirmation dialog can fire. `custom_css` is gated the same way:
31/// non-supporters can't change it (we substitute the previously-persisted
32/// value), and the value gets sanitised + clamped before write.
33/// Returns when the write hits disk.
34#[tauri::command]
35pub async fn update_settings(
36    scheduler: tauri::State<'_, Scheduler>,
37    supporter_state: tauri::State<'_, SupporterAppState>,
38    new: Settings,
39) -> Result<(), String> {
40    let is_supporter = supporter::is_supporter_now(&supporter_state.path);
41    let merged = {
42        let current = scheduler.settings.lock().await;
43        let mut m = strip_hooks(new, &current);
44        m = gate_custom_css(m, &current, is_supporter);
45        m.clamp();
46        m
47    };
48    *scheduler.settings.lock().await = merged.clone();
49    {
50        let active = scheduler.active_profile_name.lock().await.clone();
51        let mut profiles = scheduler.profiles.lock().await;
52        if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
53            p.settings = merged.clone();
54        } else {
55            profiles.push(config::Profile {
56                name: active,
57                settings: merged.clone(),
58            });
59        }
60    }
61    super::super::persist_profiles(scheduler.inner()).await;
62    Ok(())
63}
64
65// Hooks must never be set through `update_settings` — they require explicit
66// confirmation. Anything coming over the renderer IPC has its hook fields
67// overwritten with whatever is currently persisted before merge.
68fn strip_hooks(mut new: Settings, current: &Settings) -> Settings {
69    new.hooks = current.hooks.clone();
70    new.hooks_enabled = current.hooks_enabled;
71    new
72}
73
74// Non-supporters get the previously-persisted `custom_css` substituted
75// back in. That preserves a license-holder's stylesheet across a lapse +
76// re-activation, and prevents a non-supporter from ever writing one.
77fn gate_custom_css(mut new: Settings, current: &Settings, is_supporter: bool) -> Settings {
78    if !is_supporter {
79        new.custom_css = current.custom_css.clone();
80    }
81    new
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::hooks::{Hook, HookEvent};
88
89    #[test]
90    fn strip_hooks_keeps_current_hook_fields_and_takes_other_fields_from_new() {
91        let current = Settings {
92            hooks_enabled: true,
93            hooks: vec![Hook {
94                event: HookEvent::BreakStart,
95                command: "trusted".to_string(),
96                enabled: true,
97            }],
98            micro_interval_secs: 1500,
99            ..Settings::default()
100        };
101        let attacker = Settings {
102            hooks_enabled: true,
103            hooks: vec![Hook {
104                event: HookEvent::BreakEnd,
105                command: "sh -c 'curl evil'".to_string(),
106                enabled: true,
107            }],
108            micro_interval_secs: 60,
109            ..Settings::default()
110        };
111        let merged = strip_hooks(attacker, &current);
112        assert_eq!(
113            merged.micro_interval_secs, 60,
114            "non-hook fields pass through"
115        );
116        assert!(merged.hooks_enabled, "hooks_enabled comes from current");
117        assert_eq!(merged.hooks.len(), 1);
118        assert_eq!(
119            merged.hooks[0].command, "trusted",
120            "hooks come from current"
121        );
122    }
123
124    #[test]
125    fn strip_hooks_blocks_enabling_when_current_disabled() {
126        let current = Settings {
127            hooks_enabled: false,
128            hooks: vec![],
129            ..Settings::default()
130        };
131        let attacker = Settings {
132            hooks_enabled: true,
133            hooks: vec![Hook {
134                event: HookEvent::BreakStart,
135                command: "malicious".to_string(),
136                enabled: true,
137            }],
138            ..Settings::default()
139        };
140        let merged = strip_hooks(attacker, &current);
141        assert!(!merged.hooks_enabled);
142        assert!(merged.hooks.is_empty());
143    }
144
145    #[test]
146    fn gate_custom_css_substitutes_current_for_non_supporter() {
147        let current = Settings {
148            custom_css: ".saved { color: red; }".to_string(),
149            ..Settings::default()
150        };
151        let incoming = Settings {
152            custom_css: ".attempted { color: blue; }".to_string(),
153            ..Settings::default()
154        };
155        let merged = gate_custom_css(incoming, &current, false);
156        assert_eq!(
157            merged.custom_css, ".saved { color: red; }",
158            "non-supporter cannot overwrite stored CSS"
159        );
160    }
161
162    #[test]
163    fn gate_custom_css_substitutes_current_even_when_clearing() {
164        // A non-supporter renderer reads back "" (we blank on get_settings)
165        // and would naively echo that back on the next write. That must
166        // NOT clobber the persisted value.
167        let current = Settings {
168            custom_css: ".saved { color: red; }".to_string(),
169            ..Settings::default()
170        };
171        let echoed_empty = Settings {
172            custom_css: String::new(),
173            ..Settings::default()
174        };
175        let merged = gate_custom_css(echoed_empty, &current, false);
176        assert_eq!(merged.custom_css, ".saved { color: red; }");
177    }
178
179    #[test]
180    fn gate_custom_css_lets_supporter_overwrite() {
181        let current = Settings {
182            custom_css: ".old { color: red; }".to_string(),
183            ..Settings::default()
184        };
185        let incoming = Settings {
186            custom_css: ".new { color: blue; }".to_string(),
187            ..Settings::default()
188        };
189        let merged = gate_custom_css(incoming, &current, true);
190        assert_eq!(merged.custom_css, ".new { color: blue; }");
191    }
192
193    #[test]
194    fn gate_custom_css_lets_supporter_clear() {
195        let current = Settings {
196            custom_css: ".old { color: red; }".to_string(),
197            ..Settings::default()
198        };
199        let incoming = Settings {
200            custom_css: String::new(),
201            ..Settings::default()
202        };
203        let merged = gate_custom_css(incoming, &current, true);
204        assert_eq!(merged.custom_css, "");
205    }
206}