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, 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    #[cfg(feature = "sysinfo-caps")]
52    sysmon: Option<crate::sysmon::Monitor>,
53    /// Bus subscriber handle, allocated lazily on the first `sub`.
54    sub_id: Option<u64>,
55    /// Peer link handle, set when an inbound peer completes `peer_hello`.
56    peer_id: Option<u64>,
57    /// Whether this connection may run privileged commands. Local clients are
58    /// trusted; TCP clients must authenticate first when a token is configured.
59    authed: bool,
60}
61
62impl Drop for Session {
63    fn drop(&mut self) {
64        if let Some(id) = self.sub_id {
65            bus::unregister(id);
66        }
67        if let Some(id) = self.peer_id {
68            peer::unregister_link(id);
69        }
70    }
71}
72
73impl Default for Session {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl Session {
80    /// Fresh, trusted session (local socket / stdio).
81    pub fn new() -> Self {
82        Self::guarded(false)
83    }
84
85    /// Session for a connection that must authenticate before privileged use
86    /// when `require_auth` is set (inbound TCP with a token configured).
87    pub fn guarded(require_auth: bool) -> Self {
88        Session {
89            #[cfg(feature = "pty")]
90            ptys: HashMap::new(),
91            watchers: HashMap::new(),
92            #[cfg(feature = "sysinfo-caps")]
93            sysmon: None,
94            sub_id: None,
95            peer_id: None,
96            authed: !require_auth,
97        }
98    }
99
100    /// The session key for a PTY/stream request: its `id` string, or `""` for
101    /// the legacy single-terminal default.
102    fn key(req: &Value) -> String {
103        req["id"].as_str().unwrap_or("").to_string()
104    }
105
106    /// The app namespace for state ops: `app` field, or `zwire` by default.
107    fn app(req: &Value) -> String {
108        req["app"].as_str().unwrap_or("zwire").to_string()
109    }
110
111    /// Handle one request. `out` is the connection's write sink; background
112    /// capabilities (sysinfo, PTY output) keep writing to it after this returns.
113    pub fn handle(&mut self, out: &Out, msg: &Value) {
114        // Record the inbound command (tx) to the shared host log so the HUD HOST
115        // tab shows every command sent to zwire-host, from any client/process.
116        crate::hostlog::record("tx", msg, msg);
117        if let Some(cmd) = msg["cmd"].as_str() {
118            self.handle_cmd(out, msg, cmd);
119            return;
120        }
121        // Legacy commandless messages: {ui:{…}} and {scheme:"…"}.
122        if !msg["ui"].is_null() {
123            let ui = store::write_ui(&store::theme_dir(), &msg["ui"]);
124            // Notify local subscribers and every peer so all apps on all
125            // machines keep their UI prefs in sync live.
126            crate::theme_watch::note_ui(&ui); // record our own write so the watcher won't echo it
127            bus::publish("ui", &ui);
128            peer::broadcast("ui", &ui);
129            respond(out, msg, json!({"ok": true, "ui": ui}));
130        } else if let Some(s) = msg["scheme"].as_str() {
131            self.set_scheme(out, msg, s);
132        } else {
133            respond(out, msg, json!({"ok": false, "err": "empty"}));
134        }
135    }
136
137    fn handle_cmd(&mut self, out: &Out, msg: &Value, cmd: &str) {
138        // Untrusted (TCP) connections may only authenticate or do harmless
139        // discovery until they present the token.
140        if !self.authed && !matches!(cmd, "auth" | "peer_hello" | "hello" | "ping") {
141            respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
142            return;
143        }
144        match cmd {
145            "hello" | "ping" => respond(
146                out,
147                msg,
148                json!({
149                    "ok": true, "host": "zwire-host", "version": crate::VERSION,
150                    "os": std::env::consts::OS, "arch": std::env::consts::ARCH,
151                    "pid": std::process::id(), "caps": caps(),
152                }),
153            ),
154
155            /* ---- namespaced key/value store ---- */
156            "kv_get" => {
157                let v = store::kv_get(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
158                respond(out, msg, json!({"ok": true, "value": v}));
159            }
160            "kv_set" => {
161                let ok = store::kv_set(
162                    &Self::app(msg),
163                    msg["key"].as_str().unwrap_or(""),
164                    &msg["value"],
165                );
166                respond(out, msg, json!({ "ok": ok }));
167            }
168            "kv_merge" => {
169                let v = store::kv_merge(
170                    &Self::app(msg),
171                    msg["key"].as_str().unwrap_or(""),
172                    &msg["value"],
173                );
174                respond(out, msg, json!({"ok": true, "value": v}));
175            }
176            "kv_del" => {
177                let ok = store::kv_del(&Self::app(msg), msg["key"].as_str().unwrap_or(""));
178                respond(out, msg, json!({ "ok": ok }));
179            }
180            "kv_keys" => {
181                let keys = store::kv_keys(&Self::app(msg));
182                respond(out, msg, json!({"ok": true, "keys": keys}));
183            }
184
185            /* ---- legacy zwire scheme + ui + get ---- */
186            "get" => {
187                let d = store::theme_dir();
188                respond(
189                    out,
190                    msg,
191                    json!({"ok": true, "scheme": store::current_scheme(&d), "ui": store::current_ui(&d)}),
192                );
193            }
194
195            /* ---- shared host command log (HUD HOST tab) ---- */
196            "hostlog" => {
197                let n = msg["limit"].as_u64().unwrap_or(300) as usize;
198                respond(
199                    out,
200                    msg,
201                    json!({"ok": true, "log": crate::hostlog::read_tail(n)}),
202                );
203            }
204
205            /* ---- streaming file observers ---- */
206            "fs_watch" | "fs_tail" => {
207                let key = Self::key(msg);
208                let watcher = if cmd == "fs_watch" {
209                    watch::Watcher::fs_watch(out, msg, key.clone())
210                } else {
211                    watch::Watcher::fs_tail(out, msg, key.clone())
212                };
213                self.watchers.insert(key, watcher);
214                respond(out, msg, json!({"ok": true, "watching": true}));
215            }
216            "watch_stop" => {
217                self.watchers.remove(&Self::key(msg));
218                respond(out, msg, json!({"ok": true}));
219            }
220            "watch_list" => {
221                let mut ids: Vec<&String> = self.watchers.keys().collect();
222                ids.sort();
223                respond(out, msg, json!({"ok": true, "watchers": ids}));
224            }
225
226            /* ---- filesystem ---- */
227            c if c.starts_with("fs_") => {
228                let reply = fsops::handle(c, msg);
229                respond(out, msg, reply);
230            }
231
232            /* ---- subprocess ---- */
233            "exec" => respond(out, msg, exec::run(msg)),
234
235            /* ---- background jobs ---- */
236            c if c.starts_with("job_") => {
237                let reply = jobs::handle(c, msg);
238                respond(out, msg, reply);
239            }
240
241            /* ---- process tools ---- */
242            #[cfg(feature = "sysinfo-caps")]
243            "ps" | "kill" | "which" => respond(out, msg, crate::procs::handle(cmd, msg)),
244
245            /* ---- pub/sub event bus ---- */
246            "sub" => {
247                let id = *self.sub_id.get_or_insert_with(|| bus::register(out));
248                if let Some(topic) = msg["topic"].as_str() {
249                    bus::subscribe(id, topic);
250                    respond(out, msg, json!({"ok": true, "topic": topic}));
251                    // Live cross-process theme sync: once anyone cares about the
252                    // theme topics, start the shared-file watcher (idempotent).
253                    if topic == "ui" || topic == "scheme" {
254                        crate::theme_watch::ensure_started();
255                    }
256                    // Snapshot-on-subscribe for the theme topics: hand the new
257                    // subscriber the CURRENT value right away (same frame shape as
258                    // a live pub), so any client — zwire's HUD/newtab/zpwrchrome
259                    // extensions, zemacs, zpwr-daw — converges to the persisted
260                    // scheme + light/fx the instant it connects, with no separate
261                    // `get` and no polling. The host is the single source of truth.
262                    let d = store::theme_dir();
263                    match topic {
264                        "scheme" => bus::send_one(
265                            out,
266                            "scheme",
267                            &json!({ "scheme": store::current_scheme(&d) }),
268                        ),
269                        "ui" => bus::send_one(out, "ui", &store::current_ui(&d)),
270                        _ => {}
271                    }
272                } else {
273                    respond(out, msg, json!({"ok": false, "err": "no_topic"}));
274                }
275            }
276            "unsub" => {
277                if let (Some(id), Some(topic)) = (self.sub_id, msg["topic"].as_str()) {
278                    bus::unsubscribe(id, topic);
279                }
280                respond(out, msg, json!({"ok": true}));
281            }
282            "pub" => match msg["topic"].as_str() {
283                Some(topic) => {
284                    let delivered = bus::publish(topic, &msg["data"]);
285                    let forwarded = peer::broadcast(topic, &msg["data"]);
286                    respond(
287                        out,
288                        msg,
289                        json!({"ok": true, "delivered": delivered, "forwarded": forwarded}),
290                    );
291                }
292                None => respond(out, msg, json!({"ok": false, "err": "no_topic"})),
293            },
294
295            /* ---- host-to-host peering ---- */
296            "auth" => {
297                if peer::token_ok(msg["token"].as_str()) {
298                    self.authed = true;
299                    respond(out, msg, json!({"ok": true}));
300                } else {
301                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
302                }
303            }
304            "peer_hello" => {
305                if peer::token_ok(msg["token"].as_str()) {
306                    self.authed = true;
307                    let name = msg["name"].as_str().unwrap_or("peer");
308                    self.peer_id = Some(peer::register_link(out, name));
309                    respond(out, msg, json!({"ok": true, "name": peer::local_name()}));
310                } else {
311                    respond(out, msg, json!({"ok": false, "err": "unauthorized"}));
312                }
313            }
314            // A federated event from a peer: deliver locally only (never
315            // re-forward) so events can't loop around the mesh.
316            "peer_pub" => {
317                if let Some(topic) = msg["topic"].as_str() {
318                    bus::publish(topic, &msg["data"]);
319                }
320            }
321            "peers" => respond(
322                out,
323                msg,
324                json!({"ok": true, "self": peer::local_name(), "peers": peer::peer_names()}),
325            ),
326            "peer_connect" => match msg["addr"].as_str() {
327                Some(addr) => {
328                    peer::dial(addr.to_string());
329                    respond(out, msg, json!({"ok": true, "dialing": addr}));
330                }
331                None => respond(out, msg, json!({"ok": false, "err": "no_addr"})),
332            },
333            "remote" => match msg["peer"].as_str() {
334                Some(addr) => match peer::remote(addr, &msg["request"]) {
335                    Ok(reply) => {
336                        respond(out, msg, json!({"ok": true, "peer": addr, "reply": reply}))
337                    }
338                    Err(e) => respond(out, msg, json!({"ok": false, "err": e})),
339                },
340                None => respond(out, msg, json!({"ok": false, "err": "no_peer"})),
341            },
342
343            /* ---- os integration ---- */
344            "open" | "clipboard_get" | "clipboard_set" | "notify" | "hostinfo" => {
345                respond(out, msg, osops::handle(cmd, msg));
346            }
347
348            /* ---- live system stats ---- */
349            #[cfg(feature = "sysinfo-caps")]
350            "sysinfo_start" => {
351                let interval = msg["interval_ms"].as_u64().unwrap_or(2000);
352                self.sysmon = Some(crate::sysmon::Monitor::start(out, interval, Self::key(msg)));
353                respond(out, msg, json!({"ok": true, "streaming": true}));
354            }
355            #[cfg(feature = "sysinfo-caps")]
356            "sysinfo_stop" => {
357                self.sysmon = None;
358                respond(out, msg, json!({"ok": true, "streaming": false}));
359            }
360            #[cfg(feature = "sysinfo-caps")]
361            "sysinfo_once" => {
362                use sysinfo::{Disks, Networks, System};
363                let mut sys = System::new();
364                sys.refresh_cpu_usage();
365                sys.refresh_memory();
366                let nets = Networks::new_with_refreshed_list();
367                // A disk's `usage()` reports the raw counter on its first read,
368                // so a second back-to-back refresh makes the one-shot I/O delta
369                // ~0 — an honest "no rate from a single instant" rather than a
370                // lifetime total mislabelled as bytes-per-second.
371                let mut disks = Disks::new_with_refreshed_list();
372                disks.refresh(true);
373                respond(
374                    out,
375                    msg,
376                    json!({"ok": true, "sys": crate::sysmon::snapshot(1.0, &nets, &disks, &sys)}),
377                );
378            }
379
380            /* ---- multiplexed PTY terminals ---- */
381            #[cfg(feature = "pty")]
382            "pty_spawn" => {
383                let key = Self::key(msg);
384                match crate::pty::PtySession::spawn(out, msg, key.clone()) {
385                    Some(s) => {
386                        self.ptys.insert(key, s);
387                        respond(out, msg, json!({"ok": true, "spawned": true}));
388                    }
389                    None => respond(out, msg, json!({"ok": false, "err": "pty_spawn_failed"})),
390                }
391            }
392            #[cfg(feature = "pty")]
393            "pty_write" => {
394                if let Some(s) = self.ptys.get_mut(&Self::key(msg)) {
395                    s.write(msg);
396                }
397            }
398            #[cfg(feature = "pty")]
399            "pty_resize" => {
400                if let Some(s) = self.ptys.get(&Self::key(msg)) {
401                    s.resize(msg);
402                }
403            }
404            #[cfg(feature = "pty")]
405            "pty_kill" => {
406                // Dropping the session kills the child; its reader thread emits
407                // the `exit` frame.
408                self.ptys.remove(&Self::key(msg));
409            }
410
411            _ => {
412                let _ = send_msg(out, &json!({"ok": false, "err": "unknown_cmd", "cmd": cmd}));
413            }
414        }
415    }
416
417    fn set_scheme(&self, out: &Out, msg: &Value, s: &str) {
418        if store::SCHEMES.contains(&s) {
419            store::write_scheme(&store::theme_dir(), s);
420            // Push the change to every local subscriber and every peer for live
421            // cross-app, cross-machine theme sync.
422            crate::theme_watch::note_scheme(s); // record our own write so the watcher won't echo it
423            let data = json!({ "scheme": s });
424            bus::publish("scheme", &data);
425            peer::broadcast("scheme", &data);
426            respond(out, msg, json!({"ok": true, "scheme": s}));
427        } else {
428            respond(out, msg, json!({"ok": false, "err": "bad_scheme"}));
429        }
430    }
431}