entracte_lib/scheduler/commands/
hooks.rs1use std::sync::atomic::AtomicBool;
2use std::sync::Arc;
3
4use tauri::AppHandle;
5
6use crate::hooks::Hook;
7
8use super::super::Scheduler;
9
10const HOOK_DIALOG_ALLOW: &str = "Allow";
11const HOOK_DIALOG_CANCEL: &str = "Cancel";
12const HOOK_DIALOG_PER_HOOK_CHARS: usize = 120;
13const HOOK_DIALOG_MAX_HOOKS_SHOWN: usize = 5;
14const HOOK_DIALOG_MAX_BODY_CHARS: usize = 1200;
15
16struct DialogBusyGuard(Arc<AtomicBool>);
17
18impl Drop for DialogBusyGuard {
19 fn drop(&mut self) {
20 self.0.store(false, std::sync::atomic::Ordering::Release);
21 }
22}
23
24#[tauri::command]
33pub async fn set_hooks(
34 app: AppHandle,
35 scheduler: tauri::State<'_, Scheduler>,
36 hooks_enabled: bool,
37 hooks: Vec<Hook>,
38) -> Result<(), String> {
39 if scheduler
40 .hook_dialog_busy
41 .compare_exchange(
42 false,
43 true,
44 std::sync::atomic::Ordering::Acquire,
45 std::sync::atomic::Ordering::Relaxed,
46 )
47 .is_err()
48 {
49 return Err("another hook-change confirmation is already pending".to_string());
50 }
51 let _guard = DialogBusyGuard(scheduler.hook_dialog_busy.clone());
52 if !confirm_hooks_change(&app, hooks_enabled, &hooks).await {
53 return Err("user declined hook change".to_string());
54 }
55 {
56 let mut current = scheduler.settings.lock().await;
57 current.hooks_enabled = hooks_enabled;
58 current.hooks = hooks.clone();
59 }
60 {
61 let active = scheduler.active_profile_name.lock().await.clone();
62 let mut profiles = scheduler.profiles.lock().await;
63 if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
64 p.settings.hooks_enabled = hooks_enabled;
65 p.settings.hooks = hooks;
66 }
67 }
68 super::super::persist_profiles(scheduler.inner()).await;
69 Ok(())
70}
71
72async fn confirm_hooks_change(app: &AppHandle, enabled: bool, hooks: &[Hook]) -> bool {
73 use tauri_plugin_dialog::{
74 DialogExt, MessageDialogButtons, MessageDialogKind, MessageDialogResult,
75 };
76
77 let summary = format_hooks_summary(enabled, hooks);
78 let app = app.clone();
79 let (tx, rx) = tokio::sync::oneshot::channel::<MessageDialogResult>();
80 std::thread::spawn(move || {
81 let result = app
82 .dialog()
83 .message(summary)
84 .title("Entracte: confirm hook change")
85 .kind(MessageDialogKind::Warning)
86 .buttons(MessageDialogButtons::OkCancelCustom(
87 HOOK_DIALOG_CANCEL.to_string(),
88 HOOK_DIALOG_ALLOW.to_string(),
89 ))
90 .blocking_show_with_result();
91 let _ = tx.send(result);
92 });
93 match rx.await {
94 Ok(MessageDialogResult::Custom(label)) => label == HOOK_DIALOG_ALLOW,
95 _ => false,
96 }
97}
98
99fn format_hooks_summary(enabled: bool, hooks: &[Hook]) -> String {
100 let mut s = String::new();
101 s.push_str("⚠ Only click Allow if you initiated this change in Entracte's Settings.\n");
102 s.push_str("Allowing will let Entracte run the shell commands below on break events.\n\n");
103 s.push_str(&format!(
104 "Hooks will be {} after this change.\n",
105 if enabled { "ENABLED" } else { "disabled" }
106 ));
107 if hooks.is_empty() {
108 s.push_str("\nNo hooks configured.");
109 return s;
110 }
111 s.push_str(&format!("\nCommands ({}):\n", hooks.len()));
112 for h in hooks.iter().take(HOOK_DIALOG_MAX_HOOKS_SHOWN) {
113 if s.len() >= HOOK_DIALOG_MAX_BODY_CHARS {
114 break;
115 }
116 let state = if h.enabled { "on" } else { "off" };
117 s.push_str(&format!(
118 "• [{}] ({}) {}\n",
119 h.event.as_str(),
120 state,
121 sanitize_for_dialog(&h.command, HOOK_DIALOG_PER_HOOK_CHARS)
122 ));
123 }
124 if hooks.len() > HOOK_DIALOG_MAX_HOOKS_SHOWN {
125 s.push_str(&format!(
126 "... and {} more (review in Settings before allowing).\n",
127 hooks.len() - HOOK_DIALOG_MAX_HOOKS_SHOWN
128 ));
129 }
130 if s.len() > HOOK_DIALOG_MAX_BODY_CHARS {
131 s.truncate(HOOK_DIALOG_MAX_BODY_CHARS);
132 s.push_str("…\n");
133 }
134 s
135}
136
137fn sanitize_for_dialog(s: &str, max_chars: usize) -> String {
138 let mut out = String::with_capacity(s.len().min(max_chars * 4));
139 for (count, c) in s.chars().enumerate() {
140 if count >= max_chars {
141 out.push('…');
142 break;
143 }
144 let replacement = match c {
145 '\n' | '\r' | '\t' => Some('␣'),
146 c if (c as u32) < 0x20 || c as u32 == 0x7F => Some('·'),
147 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' => {
148 Some('·')
149 }
150 _ => None,
151 };
152 out.push(replacement.unwrap_or(c));
153 }
154 out
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::hooks::{Hook, HookEvent};
161
162 #[test]
163 fn format_hooks_summary_lists_each_hook() {
164 let hooks = vec![
165 Hook {
166 event: HookEvent::BreakStart,
167 command: "echo hi".to_string(),
168 enabled: true,
169 },
170 Hook {
171 event: HookEvent::PauseEnd,
172 command: "sh -c 'curl evil'".to_string(),
173 enabled: false,
174 },
175 ];
176 let s = format_hooks_summary(true, &hooks);
177 assert!(s.contains("ENABLED"));
178 assert!(s.contains("break_start"));
179 assert!(s.contains("echo hi"));
180 assert!(s.contains("pause_end"));
181 assert!(s.contains("curl evil"));
182 assert!(s.contains("(off)"));
183 }
184
185 #[test]
186 fn format_hooks_summary_puts_warning_first() {
187 let hooks = vec![Hook {
188 event: HookEvent::BreakStart,
189 command: "x".to_string(),
190 enabled: true,
191 }];
192 let s = format_hooks_summary(true, &hooks);
193 let warn_pos = s.find("Only click Allow").expect("warning present");
194 let first_hook_pos = s.find("break_start").expect("hook present");
195 assert!(
196 warn_pos < first_hook_pos,
197 "safety warning must appear before the hook list"
198 );
199 }
200
201 #[test]
202 fn format_hooks_summary_handles_empty_list() {
203 let s = format_hooks_summary(false, &[]);
204 assert!(s.contains("disabled"));
205 assert!(s.contains("No hooks configured"));
206 }
207
208 #[test]
209 fn format_hooks_summary_truncates_after_max_shown() {
210 let hooks: Vec<Hook> = (0..15)
211 .map(|i| Hook {
212 event: HookEvent::BreakStart,
213 command: format!("cmd-{i}"),
214 enabled: true,
215 })
216 .collect();
217 let s = format_hooks_summary(true, &hooks);
218 assert!(s.contains("cmd-0"));
219 assert!(s.contains(&format!("cmd-{}", HOOK_DIALOG_MAX_HOOKS_SHOWN - 1)));
220 assert!(!s.contains(&format!("cmd-{HOOK_DIALOG_MAX_HOOKS_SHOWN}")));
221 assert!(s.contains(&format!("and {} more", 15 - HOOK_DIALOG_MAX_HOOKS_SHOWN)));
222 }
223
224 #[test]
225 fn format_hooks_summary_caps_total_body() {
226 let hooks: Vec<Hook> = (0..50)
227 .map(|i| Hook {
228 event: HookEvent::BreakStart,
229 command: format!("cmd-{i}-{}", "x".repeat(100)),
230 enabled: true,
231 })
232 .collect();
233 let s = format_hooks_summary(true, &hooks);
234 assert!(
235 s.len() <= HOOK_DIALOG_MAX_BODY_CHARS + 4,
236 "body exceeded cap: {}",
237 s.len()
238 );
239 }
240
241 #[test]
242 fn sanitize_for_dialog_replaces_control_chars() {
243 let s = "a\nb\rc\td\x00e\x1fF\x7Fg";
244 let out = sanitize_for_dialog(s, 100);
245 assert!(!out.contains('\n'));
246 assert!(!out.contains('\r'));
247 assert!(!out.contains('\t'));
248 assert!(!out.contains('\x00'));
249 assert!(!out.contains('\x1f'));
250 assert!(!out.contains('\x7f'));
251 assert!(out.contains('a') && out.contains('g'));
252 }
253
254 #[test]
255 fn sanitize_for_dialog_strips_bidi_controls() {
256 let s = "hello\u{202E}olleh\u{2066}x\u{200E}y";
257 let out = sanitize_for_dialog(s, 100);
258 for bad in ['\u{202E}', '\u{2066}', '\u{200E}'] {
259 assert!(
260 !out.contains(bad),
261 "expected {:?} stripped from {:?}",
262 bad,
263 out
264 );
265 }
266 }
267
268 #[test]
269 fn sanitize_for_dialog_clips_long_strings() {
270 let s = "x".repeat(500);
271 let out = sanitize_for_dialog(&s, 100);
272 assert_eq!(out.chars().count(), 101);
273 assert!(out.ends_with('…'));
274 }
275
276 #[test]
277 fn sanitize_for_dialog_leaves_short_safe_strings_intact() {
278 assert_eq!(
279 sanitize_for_dialog("short safe text", 100),
280 "short safe text"
281 );
282 }
283
284 #[test]
285 fn dialog_constants_use_safe_default_button() {
286 assert_eq!(HOOK_DIALOG_CANCEL, "Cancel");
287 assert_eq!(HOOK_DIALOG_ALLOW, "Allow");
288 }
289
290 #[test]
291 fn dialog_busy_guard_resets_flag_on_drop() {
292 let flag = Arc::new(AtomicBool::new(true));
293 {
294 let _g = DialogBusyGuard(flag.clone());
295 }
296 assert!(!flag.load(std::sync::atomic::Ordering::Acquire));
297 }
298}