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