Skip to main content

zoi_lua/api/
system.rs

1use std::fmt::Write as _;
2use std::io::{BufRead, BufReader, Write as _};
3use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
4use std::sync::{Arc, Mutex};
5use std::thread;
6
7use mlua::{self, Lua, Table, Value};
8
9/// Represents an active shell process used for executing commands from Lua.
10struct InnerSession {
11    /// The handle to the child shell process.
12    child: Child,
13    /// The standard input stream for the child process.
14    stdin: ChildStdin,
15    /// The buffered standard output stream for the child process.
16    stdout: BufReader<ChildStdout>,
17    /// The shared buffer for capturing standard error.
18    stderr_buffer: Arc<Mutex<String>>,
19    /// A unique string used to identify the end of a command's output.
20    sentinel: String
21}
22
23impl Drop for InnerSession {
24    fn drop(&mut self) {
25        let _ = self.child.kill();
26        let _ = self.child.wait();
27    }
28}
29
30/// A wrapper around `InnerSession` providing thread-safe access.
31struct ShellSession {
32    /// The inner session state, protected by a mutex.
33    inner: Mutex<InnerSession>
34}
35
36impl ShellSession {
37    /// Creates a new `ShellSession` within the specified build directory.
38    fn new(lua: &Lua, build_dir: &str) -> Result<Self, anyhow::Error> {
39        let sentinel =
40            format!("---ZOI_CMD_COMPLETE_{}---", uuid::Uuid::new_v4());
41
42        let mut child = if cfg!(target_os = "windows") {
43            Command::new("pwsh")
44                .arg("-NoProfile")
45                .arg("-NonInteractive")
46                .arg("-Command")
47                .arg("-")
48                .current_dir(build_dir)
49                .stdin(Stdio::piped())
50                .stdout(Stdio::piped())
51                .stderr(Stdio::piped())
52                .spawn()?
53        } else {
54            Command::new("bash")
55                .arg("--noprofile")
56                .arg("--norc")
57                .current_dir(build_dir)
58                .stdin(Stdio::piped())
59                .stdout(Stdio::piped())
60                .stderr(Stdio::piped())
61                .spawn()?
62        };
63
64        let mut stdin = child.stdin.take().expect("Failed to open stdin");
65        let stdout =
66            BufReader::new(child.stdout.take().expect("Failed to open stdout"));
67        let stderr = child.stderr.take().expect("Failed to open stderr");
68
69        let stderr_buffer = Arc::new(Mutex::new(String::new()));
70        let buffer_clone = Arc::clone(&stderr_buffer);
71
72        // Spawn a thread to consume stderr continuously
73        thread::spawn(move || {
74            let mut reader = BufReader::new(stderr);
75            let mut line = String::new();
76            while reader.read_line(&mut line).unwrap_or(0) > 0 {
77                if let Ok(mut buf) = buffer_clone.lock() {
78                    buf.push_str(&line);
79                }
80                line.clear();
81            }
82        });
83
84        // Inject Zoi environment variables
85        let mut env_cmds = String::new();
86        let globals = lua.globals();
87
88        let vars = [
89            ("BUILD_TYPE", "BUILD_TYPE"),
90            ("SUBPKG", "SUBPKG"),
91            ("BUILD_DIR", "BUILD_DIR"),
92            ("STAGING_DIR", "STAGING_DIR")
93        ];
94
95        for (lua_name, env_name) in vars {
96            if let Ok(val) = globals.get::<String>(lua_name) {
97                if cfg!(target_os = "windows") {
98                    let _ = writeln!(
99                        env_cmds,
100                        "$env:{env_name} = '{}'",
101                        val.replace('\'', "''")
102                    );
103                } else {
104                    let _ = writeln!(env_cmds, "export {env_name}={val:?}");
105                }
106            }
107        }
108
109        // Handle SYSTEM and ZOI tables
110        let tables = [("SYSTEM", "SYSTEM_"), ("ZOI", "ZOI_")];
111        for (table_name, prefix) in tables {
112            if let Ok(table) = globals.get::<Table>(table_name) {
113                for (k, v) in table.pairs::<String, Value>().flatten() {
114                    let val_str = match v {
115                        Value::String(s) => s
116                            .to_str()
117                            .map_err(|e| anyhow::anyhow!(e.to_string()))?
118                            .to_string(),
119                        Value::Integer(i) => i.to_string(),
120                        Value::Number(n) => n.to_string(),
121                        Value::Boolean(b) => b.to_string(),
122                        _ => continue
123                    };
124                    let k_upper = k.to_uppercase();
125                    if cfg!(target_os = "windows") {
126                        let _ = writeln!(
127                            env_cmds,
128                            "$env:{prefix}{k_upper} = '{}'",
129                            val_str.replace('\'', "''")
130                        );
131                    } else {
132                        let _ = writeln!(
133                            env_cmds,
134                            "export {prefix}{k_upper}={val_str:?}"
135                        );
136                    }
137                }
138            }
139        }
140
141        stdin.write_all(env_cmds.as_bytes())?;
142        stdin.flush()?;
143
144        Ok(Self {
145            inner: Mutex::new(InnerSession {
146                child,
147                stdin,
148                stdout,
149                stderr_buffer,
150                sentinel
151            })
152        })
153    }
154}
155
156/// Exposes system command and patching utilities to the Lua environment.
157///
158/// # Errors
159///
160/// Returns an error if the `cmd` function cannot be registered in the Lua
161/// globals.
162///
163/// # Panics
164///
165/// Panics if the internal shell session mutex is poisoned.
166pub fn add_cmd_util(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
167    let cmd_fn = lua.create_function(move |lua, command: String| {
168        let build_dir: String = lua.globals().get("BUILD_DIR")?;
169
170        // A command such as `exit` can terminate the shell mid-call. Retry the
171        // command once against a freshly spawned session so `cmd` transparently
172        // recovers instead of failing on the next invocation.
173        let mut retried = false;
174
175        let (stdout_accum, stderr, exit_code) =
176            loop {
177                let session_is_dead =
178                    if let Some(session) = lua.app_data_ref::<ShellSession>() {
179                        let mut inner =
180                            session.inner.lock().expect("lock poisoned");
181                        inner.child.try_wait().map_or(true, |s| s.is_some())
182                    } else {
183                        true
184                    };
185
186                if retried || session_is_dead {
187                    let session =
188                        ShellSession::new(lua, &build_dir).map_err(|e| {
189                            mlua::Error::RuntimeError(e.to_string())
190                        })?;
191                    lua.set_app_data(session);
192                }
193
194                let session = lua
195                    .app_data_ref::<ShellSession>()
196                    .expect("ShellSession missing from app_data");
197                let mut inner = session.inner.lock().expect("lock poisoned");
198
199                if !quiet {
200                    println!("Executing: {command}");
201                }
202
203                // Clear stderr buffer before running command
204                if let Ok(mut buf) = inner.stderr_buffer.lock() {
205                    buf.clear();
206                }
207
208                let sentinel = inner.sentinel.clone();
209
210                if cfg!(target_os = "windows") {
211                    let cmd_text = format!(
212                        "$ErrorActionPreference = 'Continue'; & {{ {command} \
213                         }}; \"{sentinel} $LASTEXITCODE\"\n"
214                    );
215                    inner.stdin.write_all(cmd_text.as_bytes()).map_err(
216                        |e| mlua::Error::RuntimeError(e.to_string())
217                    )?;
218                    inner.stdin.flush().map_err(|e| {
219                        mlua::Error::RuntimeError(e.to_string())
220                    })?;
221                } else {
222                    let cmd_text = format!(
223                        "{{ {command} ; }} ; printf \"\\n%s %d\\n\" \
224                         {sentinel:?} $?\n"
225                    );
226                    inner.stdin.write_all(cmd_text.as_bytes()).map_err(
227                        |e| mlua::Error::RuntimeError(e.to_string())
228                    )?;
229                    inner.stdin.flush().map_err(|e| {
230                        mlua::Error::RuntimeError(e.to_string())
231                    })?;
232                }
233
234                let mut stdout_accum = String::new();
235                let mut line = String::new();
236                let mut exit_code = 0;
237
238                let mut session_died = false;
239                loop {
240                    line.clear();
241                    let n = inner.stdout.read_line(&mut line).map_err(|e| {
242                        mlua::Error::RuntimeError(e.to_string())
243                    })?;
244                    if n == 0 {
245                        session_died = true;
246                        break;
247                    }
248
249                    if let Some(idx) = line.find(&sentinel) {
250                        let out_part = &line[..idx];
251                        stdout_accum.push_str(out_part);
252
253                        let rest = line[idx..]
254                            .strip_prefix(&sentinel)
255                            .expect("sentinel missing")
256                            .trim();
257                        exit_code = rest.parse::<i32>().unwrap_or(0);
258                        break;
259                    }
260                    stdout_accum.push_str(&line);
261                }
262
263                if session_died && !retried {
264                    retried = true;
265                    continue;
266                }
267                if session_died {
268                    return Err(mlua::Error::RuntimeError(
269                        "Shell session ended unexpectedly".to_string()
270                    ));
271                }
272
273                // Retrieve captured stderr
274                let stderr = inner
275                    .stderr_buffer
276                    .lock()
277                    .map(|b| b.clone())
278                    .unwrap_or_default();
279
280                if exit_code != 0 && !quiet {
281                    eprintln!("[cmd] {stderr}");
282                }
283
284                break (stdout_accum.trim_end().to_string(), stderr, exit_code);
285            };
286
287        Ok((stdout_accum, stderr, exit_code))
288    })?;
289    lua.globals().set("cmd", cmd_fn)?;
290    Ok(())
291}
292
293/// Adds the `zpatch` function to the Lua environment for applying patches.
294///
295/// # Errors
296///
297/// Returns an error if the `zpatch` function cannot be registered in the Lua
298/// globals.
299pub fn add_zpatch(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
300    let zpatch_fn = lua.create_function(
301        move |lua, (patch_file, strip): (String, Option<u32>)| {
302            let build_dir: String = lua.globals().get("BUILD_DIR")?;
303            let strip_level = strip.unwrap_or(1);
304
305            if !quiet {
306                println!("Applying patch: {patch_file}");
307            }
308
309            let output = std::process::Command::new("patch")
310                .arg(format!("-p{strip_level}"))
311                .arg("-i")
312                .arg(&patch_file)
313                .current_dir(&build_dir)
314                .output();
315
316            match output {
317                Ok(out) => {
318                    if !out.status.success() {
319                        let stderr =
320                            String::from_utf8_lossy(&out.stderr).to_string();
321                        return Err(mlua::Error::RuntimeError(format!(
322                            "patch failed: {stderr}"
323                        )));
324                    }
325                    if !quiet {
326                        println!("Successfully applied patch {patch_file}");
327                    }
328                    Ok(())
329                }
330                Err(e) => Err(mlua::Error::RuntimeError(format!(
331                    "Failed to execute patch command: {e}"
332                )))
333            }
334        }
335    )?;
336    lua.globals().set("zpatch", zpatch_fn)?;
337    Ok(())
338}