Skip to main content

entracte_lib/scheduler/commands/
settings.rs

1use std::sync::atomic::Ordering;
2
3use crate::config;
4use crate::supporter;
5use crate::SupporterAppState;
6
7use super::super::settings::Settings;
8use super::super::Scheduler;
9
10/// Return a clone of the active profile's `Settings`. The renderer
11/// calls this on mount and again whenever the active profile changes.
12///
13/// `custom_css` is blanked for non-supporters so the renderer can't
14/// apply (or even read back) a stylesheet they aren't licensed for.
15/// The on-disk value is preserved — re-activating the license restores it.
16#[tauri::command]
17pub async fn get_settings(
18    scheduler: tauri::State<'_, Scheduler>,
19    supporter_state: tauri::State<'_, SupporterAppState>,
20) -> Result<Settings, String> {
21    let mut s = scheduler.settings.lock().await.clone();
22    if !supporter::is_supporter_now(&supporter_state.path) {
23        s.custom_css = String::new();
24    }
25    Ok(s)
26}
27
28/// Replace the active profile's settings with `new` and persist.
29///
30/// Hook fields are stripped from the payload before merge (see
31/// `strip_hooks`) — hooks must go through `set_hooks` so the user
32/// confirmation dialog can fire. `custom_css` is gated the same way:
33/// non-supporters can't change it (we substitute the previously-persisted
34/// value), and the value gets sanitised + clamped before write.
35/// Returns when the write hits disk.
36#[tauri::command]
37pub async fn update_settings(
38    app: tauri::AppHandle,
39    scheduler: tauri::State<'_, Scheduler>,
40    supporter_state: tauri::State<'_, SupporterAppState>,
41    new: Settings,
42) -> Result<(), String> {
43    let is_supporter = supporter::is_supporter_now(&supporter_state.path);
44    let merged = {
45        let current = scheduler.settings.lock().await;
46        let mut m = strip_hooks(new, &current);
47        m = gate_custom_css(m, &current, is_supporter);
48        m.clamp();
49        m
50    };
51    *scheduler.settings.lock().await = merged.clone();
52    {
53        let active = scheduler.active_profile_name.lock().await.clone();
54        let mut profiles = scheduler.profiles.lock().await;
55        if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
56            p.settings = merged.clone();
57        } else {
58            profiles.push(config::Profile {
59                name: active,
60                settings: merged.clone(),
61            });
62        }
63    }
64    super::super::persist_profiles(scheduler.inner()).await;
65    super::super::apply_hotkeys(&app, &merged);
66    Ok(())
67}
68
69/// Whether the first-run onboarding wizard still needs to be shown. The
70/// renderer calls this on mount and only mounts the wizard when it
71/// returns `false`.
72#[tauri::command]
73pub async fn get_onboarding_completed(
74    scheduler: tauri::State<'_, Scheduler>,
75) -> Result<bool, String> {
76    Ok(scheduler.onboarding_completed.load(Ordering::Relaxed))
77}
78
79/// Mark onboarding as done (the user finished or skipped the wizard) and
80/// persist it so the wizard never shows again. Idempotent.
81#[tauri::command]
82pub async fn complete_onboarding(scheduler: tauri::State<'_, Scheduler>) -> Result<(), String> {
83    complete_onboarding_impl(scheduler.inner()).await;
84    Ok(())
85}
86
87async fn complete_onboarding_impl(scheduler: &Scheduler) {
88    scheduler
89        .onboarding_completed
90        .store(true, Ordering::Relaxed);
91    super::super::persist_profiles(scheduler).await;
92}
93
94// Hooks must never be set through `update_settings` — they require explicit
95// confirmation. Anything coming over the renderer IPC has its hook fields
96// overwritten with whatever is currently persisted before merge.
97fn strip_hooks(mut new: Settings, current: &Settings) -> Settings {
98    new.hooks = current.hooks.clone();
99    new.hooks_enabled = current.hooks_enabled;
100    new
101}
102
103// Non-supporters get the previously-persisted `custom_css` substituted
104// back in. That preserves a license-holder's stylesheet across a lapse +
105// re-activation, and prevents a non-supporter from ever writing one.
106fn gate_custom_css(mut new: Settings, current: &Settings, is_supporter: bool) -> Settings {
107    if !is_supporter {
108        new.custom_css = current.custom_css.clone();
109    }
110    new
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::config::DEFAULT_PROFILE_NAME;
117    use crate::hooks::{Hook, HookEvent};
118    use crate::test_support::test_scheduler_with_profiles;
119
120    fn one_profile() -> Vec<config::Profile> {
121        vec![config::Profile {
122            name: DEFAULT_PROFILE_NAME.to_string(),
123            settings: Settings::default(),
124        }]
125    }
126
127    #[tokio::test]
128    async fn complete_onboarding_sets_flag_and_persists() {
129        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
130        // Simulate a fresh install where onboarding is still pending.
131        sched.onboarding_completed.store(false, Ordering::Relaxed);
132
133        complete_onboarding_impl(&sched).await;
134
135        assert!(sched.onboarding_completed.load(Ordering::Relaxed));
136        let reloaded = config::load(&sched.config_path);
137        assert!(
138            reloaded.onboarding_completed,
139            "completion must survive a reload from disk"
140        );
141    }
142
143    #[tokio::test]
144    async fn complete_onboarding_is_idempotent() {
145        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
146        complete_onboarding_impl(&sched).await;
147        complete_onboarding_impl(&sched).await;
148        assert!(sched.onboarding_completed.load(Ordering::Relaxed));
149    }
150
151    #[test]
152    fn strip_hooks_keeps_current_hook_fields_and_takes_other_fields_from_new() {
153        let current = Settings {
154            hooks_enabled: true,
155            hooks: vec![Hook {
156                event: HookEvent::BreakStart,
157                command: "trusted".to_string(),
158                enabled: true,
159            }],
160            micro_interval_secs: 1500,
161            ..Settings::default()
162        };
163        let attacker = Settings {
164            hooks_enabled: true,
165            hooks: vec![Hook {
166                event: HookEvent::BreakEnd,
167                command: "sh -c 'curl evil'".to_string(),
168                enabled: true,
169            }],
170            micro_interval_secs: 60,
171            ..Settings::default()
172        };
173        let merged = strip_hooks(attacker, &current);
174        assert_eq!(
175            merged.micro_interval_secs, 60,
176            "non-hook fields pass through"
177        );
178        assert!(merged.hooks_enabled, "hooks_enabled comes from current");
179        assert_eq!(merged.hooks.len(), 1);
180        assert_eq!(
181            merged.hooks[0].command, "trusted",
182            "hooks come from current"
183        );
184    }
185
186    #[test]
187    fn strip_hooks_blocks_enabling_when_current_disabled() {
188        let current = Settings {
189            hooks_enabled: false,
190            hooks: vec![],
191            ..Settings::default()
192        };
193        let attacker = Settings {
194            hooks_enabled: true,
195            hooks: vec![Hook {
196                event: HookEvent::BreakStart,
197                command: "malicious".to_string(),
198                enabled: true,
199            }],
200            ..Settings::default()
201        };
202        let merged = strip_hooks(attacker, &current);
203        assert!(!merged.hooks_enabled);
204        assert!(merged.hooks.is_empty());
205    }
206
207    #[test]
208    fn gate_custom_css_substitutes_current_for_non_supporter() {
209        let current = Settings {
210            custom_css: ".saved { color: red; }".to_string(),
211            ..Settings::default()
212        };
213        let incoming = Settings {
214            custom_css: ".attempted { color: blue; }".to_string(),
215            ..Settings::default()
216        };
217        let merged = gate_custom_css(incoming, &current, false);
218        assert_eq!(
219            merged.custom_css, ".saved { color: red; }",
220            "non-supporter cannot overwrite stored CSS"
221        );
222    }
223
224    #[test]
225    fn gate_custom_css_substitutes_current_even_when_clearing() {
226        // A non-supporter renderer reads back "" (we blank on get_settings)
227        // and would naively echo that back on the next write. That must
228        // NOT clobber the persisted value.
229        let current = Settings {
230            custom_css: ".saved { color: red; }".to_string(),
231            ..Settings::default()
232        };
233        let echoed_empty = Settings {
234            custom_css: String::new(),
235            ..Settings::default()
236        };
237        let merged = gate_custom_css(echoed_empty, &current, false);
238        assert_eq!(merged.custom_css, ".saved { color: red; }");
239    }
240
241    #[test]
242    fn gate_custom_css_lets_supporter_overwrite() {
243        let current = Settings {
244            custom_css: ".old { color: red; }".to_string(),
245            ..Settings::default()
246        };
247        let incoming = Settings {
248            custom_css: ".new { color: blue; }".to_string(),
249            ..Settings::default()
250        };
251        let merged = gate_custom_css(incoming, &current, true);
252        assert_eq!(merged.custom_css, ".new { color: blue; }");
253    }
254
255    #[test]
256    fn gate_custom_css_lets_supporter_clear() {
257        let current = Settings {
258            custom_css: ".old { color: red; }".to_string(),
259            ..Settings::default()
260        };
261        let incoming = Settings {
262            custom_css: String::new(),
263            ..Settings::default()
264        };
265        let merged = gate_custom_css(incoming, &current, true);
266        assert_eq!(merged.custom_css, "");
267    }
268}
269
270// Drive the onboarding `#[tauri::command]` wrappers end-to-end through a
271// mock app `State`. Gated off Windows for the same reason as
272// `profiles::rig_smoke_tests`: the `tauri` test feature (MockRuntime)
273// isn't compiled there. Coverage still counts — codecov merges the
274// macOS + Linux jobs.
275#[cfg(all(test, not(target_os = "windows")))]
276mod onboarding_command_rig {
277    use super::*;
278    use crate::config::DEFAULT_PROFILE_NAME;
279    use crate::test_support::{test_scheduler_with_profiles, wrap_in_mock_app};
280    use tauri::Manager;
281
282    fn one_profile() -> Vec<config::Profile> {
283        vec![config::Profile {
284            name: DEFAULT_PROFILE_NAME.to_string(),
285            settings: Settings::default(),
286        }]
287    }
288
289    #[tokio::test]
290    async fn get_onboarding_completed_command_reflects_the_flag() {
291        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
292        sched.onboarding_completed.store(false, Ordering::Relaxed);
293        let app = wrap_in_mock_app(sched.clone());
294
295        assert!(
296            !get_onboarding_completed(app.state::<Scheduler>())
297                .await
298                .unwrap(),
299            "a pending install reports incomplete"
300        );
301
302        sched.onboarding_completed.store(true, Ordering::Relaxed);
303        assert!(
304            get_onboarding_completed(app.state::<Scheduler>())
305                .await
306                .unwrap(),
307            "once flipped, the command reports complete"
308        );
309    }
310
311    #[tokio::test]
312    async fn complete_onboarding_command_sets_and_persists_the_flag() {
313        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
314        sched.onboarding_completed.store(false, Ordering::Relaxed);
315        let app = wrap_in_mock_app(sched.clone());
316
317        complete_onboarding(app.state::<Scheduler>()).await.unwrap();
318
319        assert!(sched.onboarding_completed.load(Ordering::Relaxed));
320        assert!(
321            config::load(&sched.config_path).onboarding_completed,
322            "the command persists completion to disk"
323        );
324    }
325}