Skip to main content

zwire_host/
hooks.rs

1//! Lifecycle scripting hooks, executed via the standalone `stryke` interpreter.
2//!
3//! A hook binds a lifecycle *event* (e.g. `scheme-changed`) to a stryke script.
4//! When the event fires, [`fire`] spawns the script (see [`crate::stryke_runner`]),
5//! pipes the event envelope `{ event, payload }` as JSON on stdin, and reads a
6//! `{ "actions": [ ... ] }` object back on stdout. Recognized actions are
7//! validated and dispatched against the host; unknown actions are ignored.
8//!
9//! Storage: `~/.zwire/hooks/hooks.json` holds metadata, and each hook's body
10//! lives next to it as `<id>.st`.
11//!
12//! Ported from the Audio-Haxor engine (`src-tauri/src/hooks.rs`). The model,
13//! manifest format, id/slug scheme, starter-script scaffolding, and stdout
14//! action-dispatch logic are carried over verbatim. Re-hosted onto zwire-host's
15//! substrate: Tauri commands become plain functions returning `serde_json::Value`
16//! (dispatched from `session.rs`), `app.emit` becomes `bus::publish`, and the
17//! app-specific action verbs (tag/favorite/trash tied to the Audio-Haxor DB) are
18//! replaced by zwire-host's own capabilities — notify/open/exec/pub — dispatched
19//! to the existing `osops`/`exec`/`bus` modules rather than reimplemented here.
20
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Value};
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use crate::stryke_runner::run_script;
27use crate::{bus, exec, osops, store};
28
29const DEFAULT_TIMEOUT_MS: u64 = 10_000;
30
31fn default_timeout_ms() -> u64 {
32    DEFAULT_TIMEOUT_MS
33}
34
35/// One configured hook. The script body lives in a sibling `<id>.st` file.
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct Hook {
38    #[serde(default)]
39    pub id: String,
40    pub name: String,
41    /// Lifecycle event name this hook listens to.
42    pub event: String,
43    #[serde(default)]
44    pub enabled: bool,
45    #[serde(default = "default_timeout_ms")]
46    pub timeout_ms: u64,
47}
48
49// ── paths / persistence ──
50
51/// The hooks config directory (`~/.zwire/hooks`), created on first use. zwire-host
52/// has no long-lived per-app state object like Audio-Haxor's `HooksState`, so the
53/// small manifest is read from disk per operation instead of RAM-cached.
54fn hooks_dir() -> PathBuf {
55    let d = store::app_dir("zwire").join("hooks");
56    let _ = std::fs::create_dir_all(&d);
57    d
58}
59
60fn manifest_path(dir: &Path) -> PathBuf {
61    dir.join("hooks.json")
62}
63
64fn script_path(dir: &Path, id: &str) -> PathBuf {
65    dir.join(format!("{id}.st"))
66}
67
68fn load_manifest(dir: &Path) -> Vec<Hook> {
69    let p = manifest_path(dir);
70    match std::fs::read_to_string(&p) {
71        Ok(txt) => serde_json::from_str(&txt).unwrap_or_default(),
72        Err(_) => Vec::new(),
73    }
74}
75
76fn save_manifest(dir: &Path, hooks: &[Hook]) -> Result<(), String> {
77    let txt = serde_json::to_string_pretty(hooks).map_err(|e| e.to_string())?;
78    std::fs::write(manifest_path(dir), txt).map_err(|e| e.to_string())
79}
80
81// ── firing ──
82
83/// Fire all enabled hooks bound to `event`. Returns immediately; scripts run on a
84/// background thread. The hot path (no matching hook) is one small manifest read.
85pub fn fire(event: &str, payload: Value) {
86    let dir = hooks_dir();
87    let matching: Vec<Hook> = load_manifest(&dir)
88        .into_iter()
89        .filter(|h| h.enabled && h.event == event)
90        .collect();
91    if matching.is_empty() {
92        return;
93    }
94    let event = event.to_string();
95    std::thread::spawn(move || {
96        let envelope = json!({ "event": event, "payload": payload }).to_string();
97        for h in matching {
98            let sp = script_path(&dir, &h.id);
99            if !sp.is_file() {
100                continue;
101            }
102            match run_script(&sp, &event, &envelope, Duration::from_millis(h.timeout_ms)) {
103                Ok(out) => {
104                    let done = dispatch_stdout(&out.stdout);
105                    bus::publish(
106                        "hook-result",
107                        &json!({
108                            "id": h.id,
109                            "event": event,
110                            "ok": !out.timed_out,
111                            "timedOut": out.timed_out,
112                            "code": out.code,
113                            "actions": done,
114                            "stderr": truncate(&out.stderr, 4000),
115                        }),
116                    );
117                }
118                Err(e) => {
119                    bus::publish(
120                        "hook-result",
121                        &json!({ "id": h.id, "event": event, "ok": false, "error": e }),
122                    );
123                }
124            }
125        }
126    });
127}
128
129fn truncate(s: &str, max: usize) -> String {
130    if s.len() <= max {
131        s.to_string()
132    } else {
133        format!("{}…", &s[..max])
134    }
135}
136
137// ── actions ──
138
139/// The action verbs a hook script may emit. Externally tagged: each array element
140/// looks like `{ "notify": { "title": "..", "body": ".." } }`. Where Audio-Haxor's
141/// verbs drove its media DB (tag/favorite/trash), zwire's map onto the host's own
142/// capabilities so the dispatch reuses existing `osops`/`exec`/`bus` code.
143#[derive(Debug, Deserialize)]
144#[serde(rename_all = "lowercase")]
145enum Action {
146    /// Desktop notification (via `osops::notify`).
147    Notify {
148        title: String,
149        #[serde(default)]
150        body: String,
151    },
152    /// Open a path/URL with the OS default handler (via `osops::open`).
153    Open {
154        target: String,
155    },
156    /// Run a subprocess (via `exec::run`).
157    Exec {
158        program: String,
159        #[serde(default)]
160        args: Vec<String>,
161    },
162    /// Publish an event on the host pub/sub bus (via `bus::publish`).
163    Pub {
164        topic: String,
165        #[serde(default)]
166        data: Value,
167    },
168}
169
170/// Parse the script's stdout and dispatch every recognized action. Tolerates a
171/// trailing JSON line among other output. Returns the count dispatched.
172fn dispatch_stdout(stdout: &str) -> usize {
173    let trimmed = stdout.trim();
174    if trimmed.is_empty() {
175        return 0;
176    }
177    let parsed = serde_json::from_str::<Value>(trimmed)
178        .ok()
179        .filter(|v| v.get("actions").is_some())
180        .or_else(|| last_json_line(trimmed));
181    let Some(v) = parsed else {
182        return 0;
183    };
184    let Some(arr) = v.get("actions").and_then(|a| a.as_array()) else {
185        return 0;
186    };
187    let mut done = 0;
188    for a in arr {
189        if let Ok(act) = serde_json::from_value::<Action>(a.clone()) {
190            if dispatch_action(act).is_ok() {
191                done += 1;
192            }
193        }
194    }
195    done
196}
197
198/// Scan from the end for a line that is a JSON object carrying `actions`.
199fn last_json_line(s: &str) -> Option<Value> {
200    for line in s.lines().rev() {
201        let t = line.trim();
202        if t.starts_with('{') {
203            if let Ok(v) = serde_json::from_str::<Value>(t) {
204                if v.get("actions").is_some() {
205                    return Some(v);
206                }
207            }
208        }
209    }
210    None
211}
212
213fn dispatch_action(action: Action) -> Result<(), String> {
214    let ok = |v: Value| -> bool { v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) };
215    match action {
216        Action::Notify { title, body } => {
217            osops::handle("notify", &json!({ "title": title, "body": body }));
218            Ok(())
219        }
220        Action::Open { target } => {
221            if ok(osops::handle("open", &json!({ "target": target }))) {
222                Ok(())
223            } else {
224                Err("open failed".into())
225            }
226        }
227        Action::Exec { program, args } => {
228            if ok(exec::run(&json!({ "program": program, "args": args }))) {
229                Ok(())
230            } else {
231                Err("exec failed".into())
232            }
233        }
234        Action::Pub { topic, data } => {
235            bus::publish(&topic, &data);
236            Ok(())
237        }
238    }
239}
240
241// ── id + starter script ──
242
243fn gen_id(name: &str) -> String {
244    let slug: String = name
245        .chars()
246        .map(|c| {
247            if c.is_ascii_alphanumeric() {
248                c.to_ascii_lowercase()
249            } else {
250                '-'
251            }
252        })
253        .collect();
254    let slug = slug.trim_matches('-');
255    let slug = if slug.is_empty() { "hook" } else { slug };
256    let nanos = std::time::SystemTime::now()
257        .duration_since(std::time::UNIX_EPOCH)
258        .map(|d| d.as_nanos() as u64)
259        .unwrap_or(0);
260    format!("{slug}-{nanos:x}")
261}
262
263fn default_script(event: &str) -> String {
264    // Idiomatic stryke (see strykelang docs/STYLE_GUIDE.md): `<>` reads stdin,
265    // `|>` pipelines, `val` bindings, `p` prints — not Perl 5 `my`/`print`/slurp.
266    format!(
267        "# zwire hook for event: {event}\n\
268         # Envelope arrives as one JSON line on STDIN: {{ event, payload }}\n\
269         # Print {{ actions: [ ... ] }} on STDOUT to act on the host.\n\
270         # Actions: notify, open, exec, pub.\n\n\
271         val $in = <> |> from_json\n\
272         val $out = {{ actions => [{{ notify => {{ title => \"Hook: $in->{{event}}\", body => $in->{{payload}} |> to_json }} }}] }}\n\
273         $out |> to_json |> p\n"
274    )
275}
276
277// ── command handlers (dispatched from session.rs) ──
278
279/// `hooks_list` → `{ ok, hooks: [Hook] }`.
280pub fn list() -> Value {
281    json!({ "ok": true, "hooks": load_manifest(&hooks_dir()) })
282}
283
284/// `hooks_events` → the catalog of lifecycle events + valid action verbs.
285pub fn events() -> Value {
286    json!({
287        "ok": true,
288        "events": [
289            { "name": "host-ready", "desc": "The native host started", "sample": Value::Null },
290            { "name": "navigation", "desc": "A tab navigated to a URL", "sample": { "url": "https://example.com", "tabId": 12 } },
291            { "name": "tab-created", "desc": "A tab was opened", "sample": { "tabId": 12, "url": "about:blank" } },
292            { "name": "tab-closed", "desc": "A tab was closed", "sample": { "tabId": 12 } },
293            { "name": "scheme-changed", "desc": "The HUD color scheme changed", "sample": { "scheme": "matrix" } },
294            { "name": "palette-command", "desc": "A ⌘K palette command ran", "sample": { "command": "open-devtools" } },
295            { "name": "session-saved", "desc": "A tmux/session snapshot was saved", "sample": { "name": "work" } },
296            { "name": "pane-split", "desc": "A tmux pane was split", "sample": { "dir": "h" } },
297            { "name": "audio-eq-changed", "desc": "The browser-wide audio engine config changed", "sample": { "spec": "0.0;gain,1.2" } }
298        ],
299        "actions": ["notify", "open", "exec", "pub"]
300    })
301}
302
303/// `hooks_save` — create or update a hook; scaffolds a default script for a new one.
304pub fn save(msg: &Value) -> Value {
305    let dir = hooks_dir();
306    let mut hook: Hook = match serde_json::from_value(msg["hook"].clone()) {
307        Ok(h) => h,
308        Err(e) => return json!({ "ok": false, "err": format!("bad hook: {e}") }),
309    };
310    if hook.id.trim().is_empty() {
311        hook.id = gen_id(&hook.name);
312    }
313    if hook.timeout_ms == 0 {
314        hook.timeout_ms = DEFAULT_TIMEOUT_MS;
315    }
316    let mut list = load_manifest(&dir);
317    if let Some(existing) = list.iter_mut().find(|h| h.id == hook.id) {
318        *existing = hook.clone();
319    } else {
320        let sp = script_path(&dir, &hook.id);
321        if !sp.exists() {
322            let _ = std::fs::write(&sp, default_script(&hook.event));
323        }
324        list.push(hook.clone());
325    }
326    match save_manifest(&dir, &list) {
327        Ok(()) => json!({ "ok": true, "hook": hook }),
328        Err(e) => json!({ "ok": false, "err": e }),
329    }
330}
331
332/// `hooks_delete` — drop a hook and its script file.
333pub fn delete(msg: &Value) -> Value {
334    let dir = hooks_dir();
335    let id = msg["id"].as_str().unwrap_or("");
336    let mut list = load_manifest(&dir);
337    list.retain(|h| h.id != id);
338    if let Err(e) = save_manifest(&dir, &list) {
339        return json!({ "ok": false, "err": e });
340    }
341    let _ = std::fs::remove_file(script_path(&dir, id));
342    json!({ "ok": true })
343}
344
345/// `hooks_set_enabled` — toggle a hook on/off.
346pub fn set_enabled(msg: &Value) -> Value {
347    let dir = hooks_dir();
348    let id = msg["id"].as_str().unwrap_or("");
349    let enabled = msg["enabled"].as_bool().unwrap_or(false);
350    let mut list = load_manifest(&dir);
351    if let Some(h) = list.iter_mut().find(|h| h.id == id) {
352        h.enabled = enabled;
353    }
354    match save_manifest(&dir, &list) {
355        Ok(()) => json!({ "ok": true }),
356        Err(e) => json!({ "ok": false, "err": e }),
357    }
358}
359
360/// `hooks_get_script` — the stryke source of a hook.
361pub fn get_script(msg: &Value) -> Value {
362    let dir = hooks_dir();
363    let id = msg["id"].as_str().unwrap_or("");
364    let code = std::fs::read_to_string(script_path(&dir, id)).unwrap_or_default();
365    json!({ "ok": true, "code": code })
366}
367
368/// `hooks_script_path` — absolute path to a hook's script (for the LSP `file://` URI).
369pub fn script_path_of(msg: &Value) -> Value {
370    let dir = hooks_dir();
371    let id = msg["id"].as_str().unwrap_or("");
372    json!({ "ok": true, "path": script_path(&dir, id).to_string_lossy() })
373}
374
375/// `hooks_set_script` — write a hook's stryke source.
376pub fn set_script(msg: &Value) -> Value {
377    let dir = hooks_dir();
378    let id = msg["id"].as_str().unwrap_or("");
379    let code = msg["code"].as_str().unwrap_or("");
380    match std::fs::write(script_path(&dir, id), code) {
381        Ok(()) => json!({ "ok": true }),
382        Err(e) => json!({ "ok": false, "err": e.to_string() }),
383    }
384}
385
386/// `hooks_test_run` — dry run: execute with a sample payload and return
387/// stdout/stderr plus the *parsed* actions, but do NOT dispatch them.
388pub fn test_run(msg: &Value) -> Value {
389    let dir = hooks_dir();
390    let id = msg["id"].as_str().unwrap_or("");
391    let list = load_manifest(&dir);
392    let Some(h) = list.iter().find(|h| h.id == id) else {
393        return json!({ "ok": false, "err": "hook not found" });
394    };
395    let sp = script_path(&dir, id);
396    if !sp.is_file() {
397        return json!({ "ok": false, "err": "script file missing" });
398    }
399    let payload: Value = match msg["sample"].as_str() {
400        Some(s) => serde_json::from_str(s).unwrap_or_else(|_| json!({})),
401        None => msg["sample"].clone(),
402    };
403    let envelope = json!({ "event": h.event, "payload": payload }).to_string();
404    match run_script(&sp, &h.event, &envelope, Duration::from_millis(h.timeout_ms)) {
405        Ok(out) => json!({
406            "ok": true,
407            "stdout": out.stdout,
408            "stderr": out.stderr,
409            "code": out.code,
410            "timedOut": out.timed_out,
411            "actions": parse_actions_preview(&out.stdout),
412        }),
413        Err(e) => json!({ "ok": false, "err": e }),
414    }
415}
416
417/// `hook_fire` — the browser reports a lifecycle event; run matching hooks.
418pub fn fire_cmd(msg: &Value) -> Value {
419    let Some(event) = msg["event"].as_str() else {
420        return json!({ "ok": false, "err": "no_event" });
421    };
422    fire(event, msg["payload"].clone());
423    json!({ "ok": true, "fired": event })
424}
425
426fn parse_actions_preview(stdout: &str) -> Vec<Value> {
427    let trimmed = stdout.trim();
428    let parsed = serde_json::from_str::<Value>(trimmed)
429        .ok()
430        .filter(|v| v.get("actions").is_some())
431        .or_else(|| last_json_line(trimmed));
432    let Some(v) = parsed else {
433        return Vec::new();
434    };
435    let Some(arr) = v.get("actions").and_then(|a| a.as_array()) else {
436        return Vec::new();
437    };
438    arr.iter()
439        .filter(|a| serde_json::from_value::<Action>((*a).clone()).is_ok())
440        .cloned()
441        .collect()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn parses_known_actions() {
450        let v: Vec<Value> = serde_json::from_str(
451            r#"[{"notify":{"title":"t","body":"b"}},{"exec":{"program":"echo","args":["hi"]}}]"#,
452        )
453        .unwrap();
454        let ok = v
455            .iter()
456            .filter(|a| serde_json::from_value::<Action>((*a).clone()).is_ok())
457            .count();
458        assert_eq!(ok, 2);
459    }
460
461    #[test]
462    fn skips_unknown_actions() {
463        let preview = parse_actions_preview(
464            r#"{"actions":[{"notify":{"title":"t"}},{"frobnicate":{"x":1}}]}"#,
465        );
466        assert_eq!(preview.len(), 1);
467    }
468
469    #[test]
470    fn open_requires_target() {
471        // `open` with no target must fail to deserialize (missing field).
472        assert!(serde_json::from_value::<Action>(json!({"open": {}})).is_err());
473    }
474
475    #[test]
476    fn malformed_stdout_yields_no_actions() {
477        assert!(parse_actions_preview("not json at all").is_empty());
478        assert!(parse_actions_preview("").is_empty());
479    }
480
481    #[test]
482    fn finds_trailing_json_line() {
483        let out = "log line one\nlog line two\n{\"actions\":[{\"notify\":{\"title\":\"t\",\"body\":\"b\"}}]}";
484        assert_eq!(parse_actions_preview(out).len(), 1);
485    }
486
487    #[test]
488    fn gen_id_slugifies_and_is_unique_ish() {
489        let a = gen_id("My Hook!");
490        assert!(a.starts_with("my-hook-"));
491        let b = gen_id("");
492        assert!(b.starts_with("hook-"));
493    }
494
495    #[test]
496    fn pub_action_defaults_data_null() {
497        let a: Action = serde_json::from_value(json!({"pub": {"topic": "t"}})).unwrap();
498        match a {
499            Action::Pub { data, .. } => assert!(data.is_null()),
500            _ => panic!("wrong variant"),
501        }
502    }
503}