Skip to main content

entracte_lib/scheduler/commands/
hooks.rs

1use 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/// Replace the active profile's hook list, gated by a native
25/// confirmation dialog. The dialog shows the proposed hooks (with
26/// control characters sanitised) so the user can spot tampering.
27///
28/// Returns `Err` if another `set_hooks` invocation is already showing
29/// a dialog, or if the user declines. On success, the new hooks are
30/// merged into both the in-memory settings and the active profile,
31/// then persisted to disk.
32#[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}