Skip to main content

wyvern/
viewer_spawn.rs

1//! Discover and spawn the `wyvern-viewer` subprocess for `--viewer embedded`.
2
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6
7use wyvern_host::ViewerLaunchOptions;
8
9/// Failure locating or launching `wyvern-viewer`.
10#[derive(Debug)]
11pub enum ViewerSpawnError {
12    /// Binary not found (`HOST_VIEWER_ERROR`).
13    NotFound {
14        /// Install / path hint.
15        hint: String,
16    },
17    /// Spawn I/O failure.
18    Io {
19        /// Failure detail.
20        message: String,
21    },
22}
23
24impl std::fmt::Display for ViewerSpawnError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::NotFound { hint } => write!(f, "wyvern-viewer not found; {hint}"),
28            Self::Io { message } => write!(f, "failed to spawn wyvern-viewer: {message}"),
29        }
30    }
31}
32
33impl std::error::Error for ViewerSpawnError {}
34
35/// Resolve `wyvern-viewer` via sibling → `CARGO_BIN_EXE` → `WYVERN_VIEWER_BIN` → `PATH`.
36pub fn resolve_viewer_bin() -> Result<PathBuf, ViewerSpawnError> {
37    let cargo_bin = std::env::var("CARGO_BIN_EXE_wyvern-viewer").ok();
38    let wyvern_bin = std::env::var("WYVERN_VIEWER_BIN").ok();
39    let path = std::env::var_os("PATH");
40    let exe_path = std::env::current_exe().ok();
41    let exe_dir = exe_path.as_deref().and_then(|p| p.parent());
42
43    resolve_viewer_bin_with(&ViewerResolveEnv {
44        exe_dir,
45        cargo_bin_exe: cargo_bin.as_deref(),
46        wyvern_viewer_bin: wyvern_bin.as_deref(),
47        path: path.as_deref(),
48    })
49}
50
51/// Injectable viewer discovery inputs (QA-002 — no `set_var` in unit tests).
52#[derive(Debug, Clone, Default)]
53pub struct ViewerResolveEnv<'a> {
54    /// Directory containing the running executable (sibling probe).
55    pub exe_dir: Option<&'a Path>,
56    /// `CARGO_BIN_EXE_wyvern-viewer` when set.
57    pub cargo_bin_exe: Option<&'a str>,
58    /// `WYVERN_VIEWER_BIN` when set.
59    pub wyvern_viewer_bin: Option<&'a str>,
60    /// `PATH` directories for `which` lookup.
61    pub path: Option<&'a OsStr>,
62}
63
64/// Resolve `wyvern-viewer` from injectable discovery inputs.
65pub fn resolve_viewer_bin_with(env: &ViewerResolveEnv<'_>) -> Result<PathBuf, ViewerSpawnError> {
66    if let Some(dir) = env.exe_dir {
67        let sibling = dir.join(viewer_bin_name());
68        if is_executable_file(&sibling) {
69            return Ok(sibling);
70        }
71    }
72
73    if let Some(path) = env.cargo_bin_exe {
74        let p = PathBuf::from(path);
75        if is_executable_file(&p) {
76            return Ok(p);
77        }
78    }
79
80    if let Some(path) = env.wyvern_viewer_bin {
81        let p = PathBuf::from(path);
82        if is_executable_file(&p) {
83            return Ok(p);
84        }
85        if p.is_file() {
86            return Err(ViewerSpawnError::NotFound {
87                hint: format!(
88                    "WYVERN_VIEWER_BIN='{path}' exists but is not executable; chmod +x or fix the path"
89                ),
90            });
91        }
92        return Err(ViewerSpawnError::NotFound {
93            hint: format!(
94                "WYVERN_VIEWER_BIN='{path}' is not an executable file; install wyvern-viewer or fix the path"
95            ),
96        });
97    }
98
99    if let Some(path_var) = env.path {
100        if let Some(path) = which_in_path(path_var, viewer_bin_name()) {
101            if is_executable_file(&path) {
102                return Ok(path);
103            }
104        }
105    }
106
107    Err(ViewerSpawnError::NotFound {
108        hint: "install wyvern-viewer next to wyvern, set WYVERN_VIEWER_BIN, or add it to PATH (do not silently fall back to --viewer none)".into(),
109    })
110}
111
112/// Spawn `wyvern-viewer` for `dialog_url` with optional size hints.
113///
114/// Stdin is piped so the CLI can send `exit\n` after the host accepts
115/// `POST /api/result` (parent-controlled shutdown — page does not close the window).
116///
117/// # Errors
118///
119/// Returns [`ViewerSpawnError`] when the binary is missing or spawn fails.
120pub fn spawn_embedded_viewer(
121    dialog_url: &str,
122    options: &ViewerLaunchOptions,
123) -> Result<Child, ViewerSpawnError> {
124    let bin = resolve_viewer_bin()?;
125    let mut cmd = Command::new(&bin);
126    cmd.arg(dialog_url)
127        .stdin(Stdio::piped())
128        .stdout(Stdio::null());
129    // Panics during macOS teardown must not leak Rust stack traces to the agent/user.
130    if std::env::var_os("WYVERN_VIEWER_LOG").is_some() {
131        cmd.stderr(Stdio::inherit());
132    } else {
133        cmd.stderr(Stdio::null());
134    }
135    if let Some(w) = options.width {
136        cmd.env("WYVERN_VIEWER_WIDTH", w.to_string());
137    }
138    if let Some(h) = options.height {
139        cmd.env("WYVERN_VIEWER_HEIGHT", h.to_string());
140    }
141    if let Some(title) = &options.title {
142        cmd.env("WYVERN_VIEWER_TITLE", title);
143    }
144    cmd.env("WYVERN_DIALOG_URL", dialog_url);
145    cmd.spawn().map_err(|e| ViewerSpawnError::Io {
146        message: format!("{}: {e}", bin.display()),
147    })
148}
149
150/// Ask an embedded viewer to exit after the host session completes.
151///
152/// Writes `exit\n` to the child's stdin (see `spawn_embedded_viewer`). The viewer
153/// hides and tears down on its own — avoids page-initiated close racing macOS focus.
154pub fn request_viewer_exit(child: &mut Child) {
155    use std::io::Write;
156    if let Some(stdin) = child.stdin.as_mut() {
157        let _ = stdin.write_all(b"exit\n");
158        let _ = stdin.flush();
159    }
160}
161
162/// Block until the embedded viewer exits after [`request_viewer_exit`].
163pub fn wait_for_viewer_exit(child: &mut Child) {
164    use std::thread;
165    use std::time::{Duration, Instant};
166
167    request_viewer_exit(child);
168    let deadline = Instant::now() + Duration::from_secs(10);
169    loop {
170        match child.try_wait() {
171            Ok(Some(_)) => return,
172            Ok(None) => {}
173            Err(_) => return,
174        }
175        if Instant::now() >= deadline {
176            request_viewer_exit(child);
177            let kill_deadline = Instant::now() + Duration::from_secs(5);
178            loop {
179                match child.try_wait() {
180                    Ok(Some(_)) => return,
181                    Ok(None) => {}
182                    Err(_) => return,
183                }
184                if Instant::now() >= kill_deadline {
185                    let _ = child.kill();
186                    let _ = child.wait();
187                    return;
188                }
189                thread::sleep(Duration::from_millis(50));
190            }
191        }
192        thread::sleep(Duration::from_millis(50));
193    }
194}
195
196fn viewer_bin_name() -> &'static str {
197    if cfg!(windows) {
198        "wyvern-viewer.exe"
199    } else {
200        "wyvern-viewer"
201    }
202}
203
204fn which_in_path(path_var: &OsStr, name: &str) -> Option<PathBuf> {
205    for dir in std::env::split_paths(path_var) {
206        let candidate = dir.join(name);
207        if is_executable_file(&candidate) {
208            return Some(candidate);
209        }
210        #[cfg(windows)]
211        {
212            let with_exe = dir.join(format!("{name}.exe"));
213            if is_executable_file(&with_exe) {
214                return Some(with_exe);
215            }
216        }
217    }
218    None
219}
220
221fn is_executable_file(path: &Path) -> bool {
222    if !path.is_file() {
223        return false;
224    }
225    #[cfg(unix)]
226    {
227        use std::os::unix::fs::PermissionsExt;
228        match std::fs::metadata(path) {
229            Ok(meta) => meta.permissions().mode() & 0o111 != 0,
230            Err(_) => false,
231        }
232    }
233    #[cfg(not(unix))]
234    {
235        true
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn make_executable(path: &Path) {
244        std::fs::write(path, b"#!/bin/sh\n").expect("write");
245        #[cfg(unix)]
246        {
247            use std::os::unix::fs::PermissionsExt;
248            let mut perms = std::fs::metadata(path).unwrap().permissions();
249            perms.set_mode(0o755);
250            std::fs::set_permissions(path, perms).unwrap();
251        }
252    }
253
254    #[test]
255    fn resolve_prefers_wyvern_viewer_bin_override() {
256        let tmp = tempfile::tempdir().expect("tmp");
257        let fake = tmp.path().join(viewer_bin_name());
258        make_executable(&fake);
259        let env = ViewerResolveEnv {
260            exe_dir: None,
261            cargo_bin_exe: None,
262            wyvern_viewer_bin: Some(fake.to_str().expect("utf8")),
263            path: None,
264        };
265        let resolved = resolve_viewer_bin_with(&env).expect("override");
266        assert_eq!(resolved, fake);
267    }
268
269    #[test]
270    fn resolve_errors_when_override_missing() {
271        let tmp = tempfile::tempdir().expect("tmp");
272        let missing = tmp.path().join("no-such-viewer");
273        let env = ViewerResolveEnv {
274            exe_dir: None,
275            cargo_bin_exe: None,
276            wyvern_viewer_bin: Some(missing.to_str().expect("utf8")),
277            path: None,
278        };
279        let err = resolve_viewer_bin_with(&env).expect_err("missing");
280        assert!(matches!(err, ViewerSpawnError::NotFound { .. }));
281    }
282
283    #[cfg(unix)]
284    #[test]
285    fn non_executable_bin_override_errors() {
286        let tmp = tempfile::tempdir().expect("tmp");
287        let fake = tmp.path().join("not-exec-viewer");
288        std::fs::write(&fake, b"#!/bin/sh\n").expect("write");
289        use std::os::unix::fs::PermissionsExt;
290        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
291        perms.set_mode(0o644);
292        std::fs::set_permissions(&fake, perms).unwrap();
293        assert!(!is_executable_file(&fake));
294
295        let env = ViewerResolveEnv {
296            exe_dir: None,
297            cargo_bin_exe: None,
298            wyvern_viewer_bin: Some(fake.to_str().expect("utf8")),
299            path: Some(tmp.path().as_os_str()),
300        };
301        let err = resolve_viewer_bin_with(&env).expect_err("not executable");
302        match err {
303            ViewerSpawnError::NotFound { hint } => {
304                assert!(
305                    hint.contains("not executable") || hint.contains("not an executable"),
306                    "hint={hint}"
307                );
308            }
309            other => panic!("expected NotFound, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn resolve_prefers_sibling_before_path() {
315        let tmp = tempfile::tempdir().expect("tmp");
316        let sibling = tmp.path().join(viewer_bin_name());
317        make_executable(&sibling);
318        let other = tmp.path().join("other-viewer");
319        make_executable(&other);
320        let env = ViewerResolveEnv {
321            exe_dir: Some(tmp.path()),
322            cargo_bin_exe: None,
323            wyvern_viewer_bin: None,
324            path: Some(tmp.path().as_os_str()),
325        };
326        let resolved = resolve_viewer_bin_with(&env).expect("sibling");
327        assert_eq!(resolved, sibling);
328    }
329}