Skip to main content

zwire_host/
session.rs

1//! The capability router shared by every transport.
2//!
3//! A [`Session`] is the per-connection state: its open PTYs and its sysinfo
4//! stream. [`Session::handle`] takes one decoded request plus the connection's
5//! [`Out`] sink and does the work — reply for RPC, or set up a background stream.
6//! Both the Chrome stdio loop and the socket daemon drive it the same way, so
7//! every capability is reachable from every client with no per-transport code.
8use crate::proto::{respond, send_msg, Out};
9use crate::store;
10use crate::{bus, exec, fsops, hooks, jobs, osops, peer, watch};
11use serde_json::{json, Value};
12use std::collections::HashMap;
13
14/// Capability tags advertised in the `hello` reply so a client can feature-test.
15/// The set reflects which optional capabilities were compiled in.
16pub fn caps() -> Vec<&'static str> {
17    #[cfg_attr(not(any(feature = "sysinfo-caps", feature = "pty")), allow(unused_mut))]
18    let mut c = vec![
19        "hello",
20        "kv",
21        "fs",
22        "exec",
23        "jobs",
24        "bus",
25        "peer",
26        "watch",
27        "open",
28        "clipboard",
29        "notify",
30        "hostinfo",
31        "scheme",
32        "ui",
33    ];
34    #[cfg(feature = "sysinfo-caps")]
35    {
36        c.push("sysinfo");
37        c.push("procs");
38    }
39    #[cfg(feature = "pty")]
40    c.push("pty");
41    c
42}
43
44/// Per-connection state. Dropping it tears down every PTY, stops the sysinfo
45/// stream, and drops the connection's bus subscription + peer link, so a client
46/// disconnect never leaks a shell, a thread, a subscriber, or a stale link.
47pub struct Session {
48    #[cfg(feature = "pty")]
49    ptys: HashMap<String, crate::pty::PtySession>,
50    watchers: HashMap<String, watch::Watcher>,
51    /// The `stryke --lsp` language server for the Hooks editor, if started on
52    /// this connection. Dropped (and killed) when the session ends.
53    stryke_lsp: Option<crate::stryke_lsp::StrykeLsp>,
54    #[cfg(feature = "sysinfo-caps")]
55    sysmon: Option<crate::sysmon::Monitor>,
56    /// Bus subscriber handle, allocated lazily on the first `sub`.
57    sub_id: Option<u64>,
58    /// Peer link handle, set when an inbound peer completes `peer_hello`.
59    peer_id: Option<u64>,
60    /// Whether this connection may run privileged commands. Local clients are
61    /// trusted; TCP clients must authenticate first when a token is configured.
62    authed: bool,
63}
64
65impl Drop for Session {
66    fn drop(&mut self) {
67        if let Some(id) = self.sub_id {
68            bus::unregister(id);
69        }
70        if let Some(id) = self.peer_id {
71            peer::unregister_link(id);
72        }
73    }
74}
75
76impl Default for Session {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl Session {
83    /// Fresh, trusted session (local socket / stdio).
84    pub fn new() -> Self {
85        Self::guarded(false)
86    }
87
88    /// Session for a connection that must authenticate before privileged use
89    /// when `require_auth` is set (inbound TCP with a token configured).
90    pub fn guarded(require_auth: bool) -> Self {
91        Session {
92            #[cfg(feature = "pty")]
93            ptys: HashMap::new(),
94            watchers: HashMap::new(),
95            stryke_lsp: None,
96            #[cfg(feature = "sysinfo-caps")]
97            sysmon: None,
98            sub_id: None,
99            peer_id: None,
100            authed: !require_auth,
101        }
102    }
103
104    /// The session key for a PTY/stream request: its `id` string, or `""` for
105    /// the legacy single-terminal default.
106    fn key(req: &Value) -> String {
107        req["id"].as_str().unwrap_or("").to_string()
108    }
109
110    /// The app namespace for state ops: `app` field, or `zwire` by default.
111    fn app(req: &Value) -> String {
112        req["app"].as_str().unwrap_or("zwire").to_string()
113    }
114
115    /// Handle one request. `out` is the connection's write sink; background
116    /// capabilities (sysinfo, PTY output) keep writing to it after this returns.
117    pub fn handle(&mut self, out: &Out, msg: &Value) {
118        // Record the inbound command (tx) to the shared host log so the HUD HOST
119        // tab shows every command sent to zwire-host, from any client/process.
120        crate::hostlog::record("tx", msg, msg);
121        if let Some(cmd) = msg["cmd"].as_str() {
122            self.handle_cmd(out, msg, cmd);
123            return;
124        }
125        // Legacy commandless messages: {ui:{…}} and {scheme:"…"}.
126        if !msg["ui"].is_null() {
127            let ui = store::write_ui(&store::theme_dir(), &msg["ui"]);
128            // Notify local subscribers and every peer so all apps on all
129            // machines keep their UI prefs in sync live.
130            crate::theme_watch::note_ui(&ui); // record our own write so the watcher won't echo it
131            bus::publish("ui", &ui);
132            peer::broadcast("ui", &ui);
133            respond(out, msg, json!({"ok": true, "ui": ui}));
134        } else if let Some(s) = msg["scheme"].as_str() {
135            self.set_scheme(out, msg, s);
136        } else {
137            respond(out, msg, json!({"ok": false, "err": "empty"}));
138        }
139    }
140
141    fn handle_cmd(&mut self, out: &Out, msg: &Value, cmd: &str) {
142        // Untrusted (TCP) connections may only authenticate or do harmless
143        // discovery until they present the token.
144        if !self.authed && !matches!(cmd, "auth" | "peer_hello" | "hello" | "ping") {
145            respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
146            return;
147        }
148        match cmd {
149            "hello" | "ping" => respond(
150                out,
151                msg,
152                json!({
153                    "ok": true, "host": "zwire-host", "version": crate::VERSION,
154                    "os": std::env::consts::OS, "arch": std::env::consts::ARCH,
155                    "pid": std::process::id(), "caps": caps(),
156                }),
157            ),
158
159            /* ---- namespaced key/value store ---- */
160            "kv_get" => {
161                let v = store::kv_get(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
162                respond(out, msg, json!({"ok": true, "value": v}));
163            }
164            "kv_set" => {
165                let ok = store::kv_set(
166                    &Self::app(msg),
167                    msg["key"].as_str().unwrap_or(""),
168                    &msg["value"],
169                );
170                respond(out, msg, json!({ "ok": ok }));
171            }
172            "kv_merge" => {
173                let v = store::kv_merge(
174                    &Self::app(msg),
175                    msg["key"].as_str().unwrap_or(""),
176                    &msg["value"],
177                );
178                respond(out, msg, json!({"ok": true, "value": v}));
179            }
180            "kv_del" => {
181                let ok = store::kv_del(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
182                respond(out, msg, json!({ "ok": ok }));
183            }
184            "kv_keys" => {
185                let keys = store::kv_keys(&Self::app(msg));
186                respond(out, msg, json!({"ok": true, "keys": keys}));
187            }
188
189            /* ---- legacy zwire scheme + ui + get ---- */
190            "get" => {
191                let d = store::theme_dir();
192                respond(
193                    out,
194                    msg,
195                    json!({"ok": true, "version": crate::VERSION, "scheme": store::current_scheme(&d), "ui": store::current_ui(&d)}),
196                );
197            }
198
199            /* ---- shared host command log (HUD HOST tab) ---- */
200            "hostlog" => {
201                let n = msg["limit"].as_u64().unwrap_or(300) as usize;
202                respond(
203                    out,
204                    msg,
205                    json!({"ok": true, "log": crate::hostlog::read_tail(n)}),
206                );
207            }
208
209            /* ---- stryke lifecycle hooks (ported from Audio-Haxor hooks.rs) ---- */
210            "hooks_list" => respond(out, msg, hooks::list()),
211            "hooks_events" => respond(out, msg, hooks::events()),
212            "hooks_save" => respond(out, msg, hooks::save(msg)),
213            "hooks_delete" => respond(out, msg, hooks::delete(msg)),
214            "hooks_set_enabled" => respond(out, msg, hooks::set_enabled(msg)),
215            "hooks_get_script" => respond(out, msg, hooks::get_script(msg)),
216            "hooks_script_path" => respond(out, msg, hooks::script_path_of(msg)),
217            "hooks_set_script" => respond(out, msg, hooks::set_script(msg)),
218            "hooks_test_run" => respond(out, msg, hooks::test_run(msg)),
219            "hook_fire" => respond(out, msg, hooks::fire_cmd(msg)),
220
221            /* ---- stryke language server for the Hooks editor (ported from
222               Audio-Haxor stryke_lsp.rs) — one child per connection ---- */
223            "stryke_lsp_start" => {
224                self.stryke_lsp = None; // kill any prior server first
225                match crate::stryke_lsp::StrykeLsp::start(out) {
226                    Ok(l) => {
227                        self.stryke_lsp = Some(l);
228                        respond(out, msg, json!({"ok": true}));
229                    }
230                    Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
231                }
232            }
233            "stryke_lsp_send" => {
234                let m = msg["message"].as_str().unwrap_or("");
235                match self.stryke_lsp.as_mut() {
236                    Some(l) => match l.send(m) {
237                        Ok(()) => respond(out, msg, json!({"ok": true})),
238                        Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
239                    },
240                    None => respond(
241                        out,
242                        msg,
243                        json!({"ok": false, "err": "stryke language server not running"}),
244                    ),
245                }
246            }
247            "stryke_lsp_stop" => {
248                self.stryke_lsp = None;
249                respond(out, msg, json!({"ok": true}));
250            }
251            // Run inline stryke code (`stryke -E <code>`) for the command-wizard
252            // "stryke script" step; optional `stdin`. 10s cap.
253            "stryke_run" => {
254                let code = msg["code"].as_str().unwrap_or("");
255                let stdin = msg["stdin"].as_str().unwrap_or("");
256                match crate::stryke_runner::run_code(
257                    code,
258                    stdin,
259                    std::time::Duration::from_secs(10),
260                ) {
261                    Ok(o) => respond(
262                        out,
263                        msg,
264                        json!({"ok": true, "stdout": o.stdout, "stderr": o.stderr,
265                               "code": o.code, "timedOut": o.timed_out}),
266                    ),
267                    Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
268                }
269            }
270
271            /* ---- streaming file observers ---- */
272            "fs_watch" | "fs_tail" => {
273                let key = Self::key(msg);
274                let watcher = if cmd == "fs_watch" {
275                    watch::Watcher::fs_watch(out, msg, key.clone())
276                } else {
277                    watch::Watcher::fs_tail(out, msg, key.clone())
278                };
279                self.watchers.insert(key, watcher);
280                respond(out, msg, json!({"ok": true, "watching": true}));
281            }
282            // zwire HUD — PUSH stream of the engine meter file (no page polling).
283            "meter_stream" => {
284                let key = Self::key(msg);
285                let watcher = watch::Watcher::meter_stream(out, msg, key.clone());
286                self.watchers.insert(key, watcher);
287                respond(out, msg, json!({"ok": true, "streaming": true}));
288            }
289            "watch_stop" => {
290                self.watchers.remove(&Self::key(msg));
291                respond(out, msg, json!({"ok": true}));
292            }
293            "watch_list" => {
294                let mut ids: Vec<&String> = self.watchers.keys().collect();
295                ids.sort();
296                respond(out, msg, json!({"ok": true, "watchers": ids}));
297            }
298
299            /* ---- filesystem ---- */
300            c if c.starts_with("fs_") => {
301                let reply = fsops::handle(c, msg);
302                respond(out, msg, reply);
303            }
304
305            /* ---- subprocess ---- */
306            "exec" => respond(out, msg, exec::run(msg)),
307
308            /* ---- background jobs ---- */
309            c if c.starts_with("job_") => {
310                let reply = jobs::handle(c, msg);
311                respond(out, msg, reply);
312            }
313
314            /* ---- process tools ---- */
315            #[cfg(feature = "sysinfo-caps")]
316            "ps" | "kill" | "which" => respond(out, msg, crate::procs::handle(cmd, msg)),
317
318            /* ---- pub/sub event bus ---- */
319            "sub" => {
320                let id = *self.sub_id.get_or_insert_with(|| bus::register(out));
321                if let Some(topic) = msg["topic"].as_str() {
322                    bus::subscribe(id, topic);
323                    respond(out, msg, json!({"ok": true, "topic": topic}));
324                    // Live cross-process theme sync: once anyone cares about the
325                    // theme topics, start the shared-file watcher (idempotent).
326                    if topic == "ui" || topic == "scheme" {
327                        crate::theme_watch::ensure_started();
328                    }
329                    // Snapshot-on-subscribe for the theme topics: hand the new
330                    // subscriber the CURRENT value right away (same frame shape as
331                    // a live pub), so any client — zwire's HUD/newtab/zpwrchrome
332                    // extensions, zemacs, zpwr-daw — converges to the persisted
333                    // scheme + light/fx the instant it connects, with no separate
334                    // `get` and no polling. The host is the single source of truth.
335                    let d = store::theme_dir();
336                    match topic {
337                        "scheme" => bus::send_one(
338                            out,
339                            "scheme",
340                            &json!({ "scheme": store::current_scheme(&d) }),
341                        ),
342                        "ui" => bus::send_one(out, "ui", &store::current_ui(&d)),
343                        _ => {}
344                    }
345                } else {
346                    respond(out, msg, json!({"ok": false, "err": "no_topic"}));
347                }
348            }
349            "unsub" => {
350                if let (Some(id), Some(topic)) = (self.sub_id, msg["topic"].as_str()) {
351                    bus::unsubscribe(id, topic);
352                }
353                respond(out, msg, json!({"ok": true}));
354            }
355            "pub" => match msg["topic"].as_str() {
356                Some(topic) => {
357                    let delivered = bus::publish(topic, &msg["data"]);
358                    let forwarded = peer::broadcast(topic, &msg["data"]);
359                    respond(
360                        out,
361                        msg,
362                        json!({"ok": true, "delivered": delivered, "forwarded": forwarded}),
363                    );
364                }
365                None => respond(out, msg, json!({"ok": false, "err": "no_topic"})),
366            },
367
368            /* ---- host-to-host peering ---- */
369            "auth" => {
370                if peer::token_ok(msg["token"].as_str()) {
371                    self.authed = true;
372                    respond(out, msg, json!({"ok": true}));
373                } else {
374                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
375                }
376            }
377            "peer_hello" => {
378                if peer::token_ok(msg["token"].as_str()) {
379                    self.authed = true;
380                    let name = msg["name"].as_str().unwrap_or("peer");
381                    self.peer_id = Some(peer::register_link(out, name));
382                    respond(out, msg, json!({"ok": true, "name": peer::local_name()}));
383                } else {
384                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
385                }
386            }
387            // A federated event from a peer: deliver locally only (never
388            // re-forward) so events can't loop around the mesh.
389            "peer_pub" => {
390                if let Some(topic) = msg["topic"].as_str() {
391                    bus::publish(topic, &msg["data"]);
392                }
393            }
394            "peers" => respond(
395                out,
396                msg,
397                json!({"ok": true, "self": peer::local_name(), "peers": peer::peer_names()}),
398            ),
399            "peer_connect" => match msg["addr"].as_str() {
400                Some(addr) => {
401                    peer::dial(addr.to_string());
402                    respond(out, msg, json!({"ok": true, "dialing": addr}));
403                }
404                None => respond(out, msg, json!({"ok": false, "err": "no_addr"})),
405            },
406            "remote" => match msg["peer"].as_str() {
407                Some(addr) => match peer::remote(addr, &msg["request"]) {
408                    Ok(reply) => {
409                        respond(out, msg, json!({"ok": true, "peer": addr, "reply": reply}))
410                    }
411                    Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
412                },
413                None => respond(out, msg, json!({"ok": false, "err": "no_peer"})),
414            },
415
416            /* ---- os integration ---- */
417            "open" | "clipboard_get" | "clipboard_set" | "notify" | "hostinfo" => {
418                respond(out, msg, osops::handle(cmd, msg));
419            }
420
421            /* ---- live system stats ---- */
422            #[cfg(feature = "sysinfo-caps")]
423            "sysinfo_start" => {
424                let interval = msg["interval_ms"].as_u64().unwrap_or(2000);
425                self.sysmon = Some(crate::sysmon::Monitor::start(out, interval, Self::key(msg)));
426                respond(out, msg, json!({"ok": true, "streaming": true}));
427            }
428            #[cfg(feature = "sysinfo-caps")]
429            "sysinfo_stop" => {
430                self.sysmon = None;
431                respond(out, msg, json!({"ok": true, "streaming": false}));
432            }
433            #[cfg(feature = "sysinfo-caps")]
434            "sysinfo_once" => {
435                use sysinfo::{Disks, Networks, System};
436                let mut sys = System::new();
437                sys.refresh_cpu_usage();
438                sys.refresh_memory();
439                let nets = Networks::new_with_refreshed_list();
440                // A disk's `usage()` reports the raw counter on its first read,
441                // so a second back-to-back refresh makes the one-shot I/O delta
442                // ~0 — an honest "no rate from a single instant" rather than a
443                // lifetime total mislabelled as bytes-per-second.
444                let mut disks = Disks::new_with_refreshed_list();
445                disks.refresh(true);
446                respond(
447                    out,
448                    msg,
449                    json!({"ok": true, "sys": crate::sysmon::snapshot(1.0, &nets, &disks, &sys)}),
450                );
451            }
452
453            /* ---- multiplexed PTY terminals ---- */
454            #[cfg(feature = "pty")]
455            "pty_spawn" => {
456                let key = Self::key(msg);
457                match crate::pty::PtySession::spawn(out, msg, key.clone()) {
458                    Some(s) => {
459                        self.ptys.insert(key, s);
460                        respond(out, msg, json!({"ok": true, "spawned": true}));
461                    }
462                    None => respond(out, msg, json!({"ok": false, "err": "pty_spawn_failed"})),
463                }
464            }
465            #[cfg(feature = "pty")]
466            "pty_write" => {
467                if let Some(s) = self.ptys.get_mut(&Self::key(msg)) {
468                    s.write(msg);
469                }
470            }
471            #[cfg(feature = "pty")]
472            "pty_resize" => {
473                if let Some(s) = self.ptys.get(&Self::key(msg)) {
474                    s.resize(msg);
475                }
476            }
477            #[cfg(feature = "pty")]
478            "pty_kill" => {
479                // Dropping the session kills the child; its reader thread emits
480                // the `exit` frame.
481                self.ptys.remove(&Self::key(msg));
482            }
483
484            _ => {
485                let _ = send_msg(out, &json!({"ok": false, "err": "unknown_cmd", "cmd": cmd}));
486            }
487        }
488    }
489
490    fn set_scheme(&self, out: &Out, msg: &Value, s: &str) {
491        if store::SCHEMES.contains(&s) {
492            store::write_scheme(&store::theme_dir(), s);
493            // Push the change to every local subscriber and every peer for live
494            // cross-app, cross-machine theme sync.
495            crate::theme_watch::note_scheme(s); // record our own write so the watcher won't echo it
496            let data = json!({ "scheme": s });
497            bus::publish("scheme", &data);
498            peer::broadcast("scheme", &data);
499            respond(out, msg, json!({"ok": true, "scheme": s}));
500        } else {
501            respond(out, msg, json!({"ok": false, "err": "bad_scheme"}));
502        }
503    }
504}