1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct Hook {
38 #[serde(default)]
39 pub id: String,
40 pub name: String,
41 pub event: String,
43 #[serde(default)]
44 pub enabled: bool,
45 #[serde(default = "default_timeout_ms")]
46 pub timeout_ms: u64,
47}
48
49fn 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
81pub 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#[derive(Debug, Deserialize)]
144#[serde(rename_all = "lowercase")]
145enum Action {
146 Notify {
148 title: String,
149 #[serde(default)]
150 body: String,
151 },
152 Open {
154 target: String,
155 },
156 Exec {
158 program: String,
159 #[serde(default)]
160 args: Vec<String>,
161 },
162 Pub {
164 topic: String,
165 #[serde(default)]
166 data: Value,
167 },
168}
169
170fn 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
198fn 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
241fn 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 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
277pub fn list() -> Value {
281 json!({ "ok": true, "hooks": load_manifest(&hooks_dir()) })
282}
283
284pub 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
303pub 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
332pub 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
345pub 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
360pub 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
368pub 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
375pub 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
386pub 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
417pub 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 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}