entracte_lib/scheduler/commands/
content_pack.rs1use std::path::Path;
2
3use tauri::{Runtime, WebviewWindow};
4
5use crate::scheduler::content_pack::{
6 export_pack, merge_pack, parse_pack, serialize_pack, validate_pack, ContentPack, MergeSummary,
7};
8use crate::secure_io::{read_capped, write_user_only};
9
10use super::super::Scheduler;
11
12const MAIN_WINDOW_LABEL: &str = "main";
14const MAX_PACK_BYTES: u64 = 8 * 1024 * 1024;
18
19fn ensure_main_window<R: Runtime>(webview: &WebviewWindow<R>) -> Result<(), String> {
20 if webview.label() != MAIN_WINDOW_LABEL {
21 return Err("content-pack commands are restricted to the main window".to_string());
22 }
23 Ok(())
24}
25
26#[tauri::command]
31pub async fn import_content_pack<R: Runtime>(
32 webview: WebviewWindow<R>,
33 scheduler: tauri::State<'_, Scheduler>,
34 path: String,
35) -> Result<MergeSummary, String> {
36 ensure_main_window(&webview)?;
37 let text = read_capped(Path::new(&path), MAX_PACK_BYTES).map_err(|e| match e.kind() {
38 std::io::ErrorKind::InvalidData => {
39 format!(
40 "content-pack file is too large (over {} MiB)",
41 MAX_PACK_BYTES / (1024 * 1024)
42 )
43 }
44 _ => format!("failed to read content-pack file: {e}"),
45 })?;
46 let pack = parse_pack(&text)?;
47 validate_pack(&pack)?;
48 Ok(apply_pack(scheduler.inner(), &pack).await)
49}
50
51async fn apply_pack(scheduler: &Scheduler, pack: &ContentPack) -> MergeSummary {
59 let (merged, summary) = {
60 let mut next = scheduler.settings.lock().await.clone();
61 let summary = merge_pack(pack, &mut next);
62 next.rebuild_derived();
63 (next, summary)
64 };
65 *scheduler.settings.lock().await = merged.clone();
66 {
67 let active = scheduler.active_profile_name.lock().await.clone();
68 let mut profiles = scheduler.profiles.lock().await;
69 if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
70 p.settings = merged;
71 } else {
72 profiles.push(crate::config::Profile {
73 name: active,
74 settings: merged,
75 });
76 }
77 }
78 super::super::persist_profiles(scheduler).await;
79 summary
80}
81
82#[tauri::command]
85pub async fn export_content_pack<R: Runtime>(
86 webview: WebviewWindow<R>,
87 scheduler: tauri::State<'_, Scheduler>,
88 path: String,
89 name: String,
90) -> Result<(), String> {
91 ensure_main_window(&webview)?;
92 let pack = {
93 let s = scheduler.settings.lock().await;
94 export_pack(&name, &s)
95 };
96 let text = serialize_pack(&pack)?;
97 write_user_only(Path::new(&path), text.as_bytes())
98 .map_err(|e| format!("failed to write content-pack file: {e}"))?;
99 Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::scheduler::content_pack::{PackHints, CONTENT_PACK_VERSION};
106 use crate::scheduler::routines::{Routine, RoutineCategory, RoutineDifficulty, RoutineKind};
107 use crate::scheduler::types::RoutineStep;
108 use crate::scheduler::BreakKind;
109 use crate::test_support::test_scheduler;
110
111 fn sample_pack() -> ContentPack {
112 ContentPack {
113 version: CONTENT_PACK_VERSION,
114 name: "Test".to_string(),
115 hints: PackHints {
116 micro_physical: vec!["An imported micro idea".to_string()],
117 ..PackHints::default()
118 },
119 routines: vec![Routine {
120 id: "imported-rt".to_string(),
121 label: "Imported".to_string(),
122 kind: RoutineKind::Micro,
123 category: RoutineCategory::Eyes,
124 difficulty: RoutineDifficulty::Gentle,
125 steps: vec![RoutineStep {
126 text: "Look away".to_string(),
127 seconds: 5,
128 asset: None,
129 sound: None,
130 }],
131 pacing: None,
132 max_step_secs: None,
133 breath: None,
134 }],
135 }
136 }
137
138 #[tokio::test]
139 async fn apply_pack_merges_persists_and_rebuilds_the_derived_cache() {
140 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
141 let summary = apply_pack(&sched, &sample_pack()).await;
142 assert_eq!(summary.hints_added, 1);
143 assert_eq!(summary.routines_added, 1);
144
145 let s = sched.settings.lock().await;
146 assert!(s
148 .micro_physical_hints
149 .contains(&"An imported micro idea".to_string()));
150 assert!(s
153 .effective_hints(BreakKind::Micro)
154 .contains(&"An imported micro idea".to_string()));
155 assert!(s.custom_routines.iter().any(|r| r.id == "imported-rt"));
156 }
157
158 #[tokio::test]
159 async fn apply_pack_writes_into_the_active_profile() {
160 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
161 apply_pack(&sched, &sample_pack()).await;
162 let active = sched.active_profile_name.lock().await.clone();
163 let profiles = sched.profiles.lock().await;
164 let p = profiles
165 .iter()
166 .find(|p| p.name == active)
167 .expect("active profile present");
168 assert!(p
169 .settings
170 .custom_routines
171 .iter()
172 .any(|r| r.id == "imported-rt"));
173 }
174
175 #[tokio::test]
176 async fn apply_pack_pushes_active_profile_when_absent() {
177 let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
178 *sched.active_profile_name.lock().await = "Ghost".to_string();
180 apply_pack(&sched, &sample_pack()).await;
181 let profiles = sched.profiles.lock().await;
182 let ghost = profiles
183 .iter()
184 .find(|p| p.name == "Ghost")
185 .expect("absent active profile was pushed");
186 assert!(ghost
187 .settings
188 .custom_routines
189 .iter()
190 .any(|r| r.id == "imported-rt"));
191 }
192}