Skip to main content

zwire_host/
api.rs

1//! High-level, in-process helpers for crates that embed this one as a library.
2//!
3//! Sibling hosts such as `zpwrchrome-host` depend on `zwire-host` to **crawl the
4//! filesystem** and **run commands** without speaking the wire protocol or
5//! standing up a daemon — they just call these functions and get native Rust
6//! values back:
7//!
8//! ```no_run
9//! // in zpwrchrome-host:
10//! for entry in zwire_host::api::walk("~/src", Some("rs")) {
11//!     println!("{}", entry.path.display());
12//! }
13//! let out = zwire_host::api::exec("git", ["status", "--porcelain"]).unwrap();
14//! println!("git exited {:?}:\n{}", out.code, String::from_utf8_lossy(&out.stdout));
15//! ```
16//!
17//! Everything here is a thin, allocation-light wrapper over the same capability
18//! functions the transports call, so behaviour is identical in-process and over
19//! the socket.
20use crate::proto::b64_decode;
21use serde_json::{json, Value};
22use std::path::PathBuf;
23
24/// Captured result of [`exec`].
25#[derive(Debug, Clone)]
26pub struct ExecOutput {
27    /// Process exit code, or `None` if it was killed by a signal.
28    pub code: Option<i64>,
29    /// Raw stdout bytes.
30    pub stdout: Vec<u8>,
31    /// Raw stderr bytes.
32    pub stderr: Vec<u8>,
33}
34
35impl ExecOutput {
36    /// stdout decoded lossily as UTF-8, trailing newline trimmed.
37    pub fn stdout_str(&self) -> String {
38        String::from_utf8_lossy(&self.stdout).trim_end().to_string()
39    }
40}
41
42/// Run `program args...` to completion in-process and capture its output.
43/// Errors carry the failure reason (e.g. the program was not found).
44pub fn exec<I, S>(program: &str, args: I) -> Result<ExecOutput, String>
45where
46    I: IntoIterator<Item = S>,
47    S: AsRef<str>,
48{
49    let args: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
50    let v = crate::exec::run(&json!({ "program": program, "args": args }));
51    if v["ok"] != json!(true) {
52        return Err(v["err"].as_str().unwrap_or("exec_failed").to_string());
53    }
54    Ok(ExecOutput {
55        code: v["code"].as_i64(),
56        stdout: v["stdout"]
57            .as_str()
58            .and_then(b64_decode)
59            .unwrap_or_default(),
60        stderr: v["stderr"]
61            .as_str()
62            .and_then(b64_decode)
63            .unwrap_or_default(),
64    })
65}
66
67/// Run `program` with `input` fed to its stdin.
68pub fn exec_stdin<I, S>(program: &str, args: I, input: &str) -> Result<ExecOutput, String>
69where
70    I: IntoIterator<Item = S>,
71    S: AsRef<str>,
72{
73    let args: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
74    let v = crate::exec::run(&json!({ "program": program, "args": args, "stdin": input }));
75    if v["ok"] != json!(true) {
76        return Err(v["err"].as_str().unwrap_or("exec_failed").to_string());
77    }
78    Ok(ExecOutput {
79        code: v["code"].as_i64(),
80        stdout: v["stdout"]
81            .as_str()
82            .and_then(b64_decode)
83            .unwrap_or_default(),
84        stderr: v["stderr"]
85            .as_str()
86            .and_then(b64_decode)
87            .unwrap_or_default(),
88    })
89}
90
91/// One node found by [`walk`].
92#[derive(Debug, Clone)]
93pub struct Entry {
94    /// Absolute path to the entry.
95    pub path: PathBuf,
96    /// Leaf file name.
97    pub name: String,
98    /// Whether the entry is a directory.
99    pub dir: bool,
100    /// File size in bytes (0 for directories / on stat failure).
101    pub size: u64,
102}
103
104/// Recursively crawl `root` (a `~`-expandable path). `ext`, when given, keeps
105/// only files with that extension (no leading dot). Capped internally so a crawl
106/// of a huge tree can't run away.
107pub fn walk(root: &str, ext: Option<&str>) -> Vec<Entry> {
108    let mut req = json!({ "path": root });
109    if let Some(x) = ext {
110        req["ext"] = json!(x);
111    }
112    entries_from(crate::fsops::handle("fs_walk", &req))
113}
114
115/// Like [`walk`] but with the full filter set: `depth`, `dirs_only`, `ext`,
116/// `contains` (substring match on the leaf name).
117pub fn walk_filtered(
118    root: &str,
119    depth: Option<usize>,
120    dirs_only: bool,
121    ext: Option<&str>,
122    contains: Option<&str>,
123) -> Vec<Entry> {
124    let mut req = json!({ "path": root, "dirs_only": dirs_only });
125    if let Some(d) = depth {
126        req["depth"] = json!(d);
127    }
128    if let Some(x) = ext {
129        req["ext"] = json!(x);
130    }
131    if let Some(c) = contains {
132        req["contains"] = json!(c);
133    }
134    entries_from(crate::fsops::handle("fs_walk", &req))
135}
136
137fn entries_from(v: Value) -> Vec<Entry> {
138    v["entries"]
139        .as_array()
140        .map(|a| {
141            a.iter()
142                .map(|e| Entry {
143                    path: PathBuf::from(e["path"].as_str().unwrap_or("")),
144                    name: e["name"].as_str().unwrap_or("").to_string(),
145                    dir: e["dir"].as_bool().unwrap_or(false),
146                    size: e["size"].as_u64().unwrap_or(0),
147                })
148                .collect()
149        })
150        .unwrap_or_default()
151}
152
153/// Read a whole file (`~`-expandable path) into bytes.
154pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
155    let v = crate::fsops::handle("fs_read", &json!({ "path": path }));
156    if v["ok"] != json!(true) {
157        return Err(v["err"].as_str().unwrap_or("read_failed").to_string());
158    }
159    Ok(v["b64"].as_str().and_then(b64_decode).unwrap_or_default())
160}
161
162/// Write bytes to a file (`~`-expandable path), creating parent dirs.
163pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
164    let v = crate::fsops::handle(
165        "fs_write",
166        &json!({ "path": path, "b64": crate::proto::b64_encode(bytes) }),
167    );
168    if v["ok"] == json!(true) {
169        Ok(())
170    } else {
171        Err(v["err"].as_str().unwrap_or("write_failed").to_string())
172    }
173}
174
175// ─────────────────────────── shared fleet theme ───────────────────────────
176// Helpers for an app backend (Tauri/JUCE) to sync colour scheme + light/fx with
177// the fleet-wide theme (~/.zwire/global.toml). Read + write are in-process store
178// ops; `theme_watch` bridges live changes (from THIS or ANY app) to a callback,
179// which a backend forwards to its webview as a `theme-changed` event. Pairs with
180// zgui-core's `ZGui.themeSync` on the frontend.
181
182/// The current shared theme as `(scheme, ui)`, where `ui` is the light/fx object.
183pub fn theme_get() -> (String, Value) {
184    let d = crate::store::theme_dir();
185    (
186        crate::store::current_scheme(&d),
187        crate::store::current_ui(&d),
188    )
189}
190
191/// Set the shared colour scheme: persist to `global.toml` (+ the `hud-scheme`
192/// projection) and notify every local subscriber + peer so the fleet follows.
193pub fn theme_set_scheme(scheme: &str) {
194    let d = crate::store::theme_dir();
195    crate::store::write_scheme(&d, scheme);
196    crate::theme_watch::note_scheme(scheme);
197    let data = json!({ "scheme": scheme });
198    crate::bus::publish("scheme", &data);
199    crate::peer::broadcast("scheme", &data);
200}
201
202/// Merge a partial light/fx object (e.g. `{"light":true}`) into the shared ui and
203/// notify subscribers + peers. Returns the merged ui.
204pub fn theme_set_ui(partial: &Value) -> Value {
205    let d = crate::store::theme_dir();
206    let ui = crate::store::write_ui(&d, partial);
207    crate::theme_watch::note_ui(&ui);
208    crate::bus::publish("ui", &ui);
209    crate::peer::broadcast("ui", &ui);
210    ui
211}
212
213/// Watch the shared theme for changes and invoke `on_change(scheme, ui)` — once
214/// immediately (so the caller converges to the current value) and thereafter
215/// whenever `global.toml` changes, from this app or any other. Spawns a
216/// background thread and returns at once. An app backend uses this to push live
217/// theme updates into its UI (emit a Tauri/JUCE `theme-changed` event).
218pub fn theme_watch<F>(on_change: F)
219where
220    F: Fn(String, Value) + Send + 'static,
221{
222    std::thread::spawn(move || {
223        let d = crate::store::theme_dir();
224        let mut last = (
225            crate::store::current_scheme(&d),
226            crate::store::current_ui(&d),
227        );
228        on_change(last.0.clone(), last.1.clone());
229        loop {
230            std::thread::sleep(std::time::Duration::from_millis(500));
231            let cur = (
232                crate::store::current_scheme(&d),
233                crate::store::current_ui(&d),
234            );
235            if cur != last {
236                last = cur.clone();
237                on_change(cur.0, cur.1);
238            }
239        }
240    });
241}