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 _ = child.wait();
178            return;
179        }
180        thread::sleep(Duration::from_millis(50));
181    }
182}
183
184fn viewer_bin_name() -> &'static str {
185    if cfg!(windows) {
186        "wyvern-viewer.exe"
187    } else {
188        "wyvern-viewer"
189    }
190}
191
192fn which_in_path(path_var: &OsStr, name: &str) -> Option<PathBuf> {
193    for dir in std::env::split_paths(path_var) {
194        let candidate = dir.join(name);
195        if is_executable_file(&candidate) {
196            return Some(candidate);
197        }
198        #[cfg(windows)]
199        {
200            let with_exe = dir.join(format!("{name}.exe"));
201            if is_executable_file(&with_exe) {
202                return Some(with_exe);
203            }
204        }
205    }
206    None
207}
208
209fn is_executable_file(path: &Path) -> bool {
210    if !path.is_file() {
211        return false;
212    }
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::PermissionsExt;
216        match std::fs::metadata(path) {
217            Ok(meta) => meta.permissions().mode() & 0o111 != 0,
218            Err(_) => false,
219        }
220    }
221    #[cfg(not(unix))]
222    {
223        true
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn make_executable(path: &Path) {
232        std::fs::write(path, b"#!/bin/sh\n").expect("write");
233        #[cfg(unix)]
234        {
235            use std::os::unix::fs::PermissionsExt;
236            let mut perms = std::fs::metadata(path).unwrap().permissions();
237            perms.set_mode(0o755);
238            std::fs::set_permissions(path, perms).unwrap();
239        }
240    }
241
242    #[test]
243    fn resolve_prefers_wyvern_viewer_bin_override() {
244        let tmp = tempfile::tempdir().expect("tmp");
245        let fake = tmp.path().join(viewer_bin_name());
246        make_executable(&fake);
247        let env = ViewerResolveEnv {
248            exe_dir: None,
249            cargo_bin_exe: None,
250            wyvern_viewer_bin: Some(fake.to_str().expect("utf8")),
251            path: None,
252        };
253        let resolved = resolve_viewer_bin_with(&env).expect("override");
254        assert_eq!(resolved, fake);
255    }
256
257    #[test]
258    fn resolve_errors_when_override_missing() {
259        let tmp = tempfile::tempdir().expect("tmp");
260        let missing = tmp.path().join("no-such-viewer");
261        let env = ViewerResolveEnv {
262            exe_dir: None,
263            cargo_bin_exe: None,
264            wyvern_viewer_bin: Some(missing.to_str().expect("utf8")),
265            path: None,
266        };
267        let err = resolve_viewer_bin_with(&env).expect_err("missing");
268        assert!(matches!(err, ViewerSpawnError::NotFound { .. }));
269    }
270
271    #[cfg(unix)]
272    #[test]
273    fn non_executable_bin_override_errors() {
274        let tmp = tempfile::tempdir().expect("tmp");
275        let fake = tmp.path().join("not-exec-viewer");
276        std::fs::write(&fake, b"#!/bin/sh\n").expect("write");
277        use std::os::unix::fs::PermissionsExt;
278        let mut perms = std::fs::metadata(&fake).unwrap().permissions();
279        perms.set_mode(0o644);
280        std::fs::set_permissions(&fake, perms).unwrap();
281        assert!(!is_executable_file(&fake));
282
283        let env = ViewerResolveEnv {
284            exe_dir: None,
285            cargo_bin_exe: None,
286            wyvern_viewer_bin: Some(fake.to_str().expect("utf8")),
287            path: Some(tmp.path().as_os_str()),
288        };
289        let err = resolve_viewer_bin_with(&env).expect_err("not executable");
290        match err {
291            ViewerSpawnError::NotFound { hint } => {
292                assert!(
293                    hint.contains("not executable") || hint.contains("not an executable"),
294                    "hint={hint}"
295                );
296            }
297            other => panic!("expected NotFound, got {other:?}"),
298        }
299    }
300
301    #[test]
302    fn resolve_prefers_sibling_before_path() {
303        let tmp = tempfile::tempdir().expect("tmp");
304        let sibling = tmp.path().join(viewer_bin_name());
305        make_executable(&sibling);
306        let other = tmp.path().join("other-viewer");
307        make_executable(&other);
308        let env = ViewerResolveEnv {
309            exe_dir: Some(tmp.path()),
310            cargo_bin_exe: None,
311            wyvern_viewer_bin: None,
312            path: Some(tmp.path().as_os_str()),
313        };
314        let resolved = resolve_viewer_bin_with(&env).expect("sibling");
315        assert_eq!(resolved, sibling);
316    }
317}