Skip to main content

entracte_lib/scheduler/commands/
hooks.rs

1use 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/// Run a single hook command once and return its captured stdout/stderr and
11/// exit status, so the Settings "Test" button can show the user what a
12/// command does before they rely on it. Runs with a representative
13/// `$ENTRACTE_*` env and a short timeout, off the async runtime since the
14/// capture blocks. No confirmation dialog: the user typed and triggered this
15/// command themselves — the dialog guards *persisting* hooks, not a transient
16/// user-initiated test.
17#[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/// Replace the active profile's hook list, gated by a native
45/// confirmation dialog. The dialog shows the proposed hooks (with
46/// control characters sanitised) so the user can spot tampering.
47///
48/// Returns `Err` if another `set_hooks` invocation is already showing
49/// a dialog, or if the user declines. On success, the new hooks are
50/// merged into both the in-memory settings and the active profile,
51/// then persisted to disk.
52#[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}