entracte_lib/scheduler/commands/
settings.rs1use 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#[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#[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, ¤t);
47 m = gate_custom_css(m, ¤t, 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#[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#[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
94fn 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
103fn 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 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, ¤t);
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, ¤t);
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, ¤t, 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 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, ¤t, 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, ¤t, 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, ¤t, true);
266 assert_eq!(merged.custom_css, "");
267 }
268}
269
270#[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}