1use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6
7use wyvern_host::ViewerLaunchOptions;
8
9#[derive(Debug)]
11pub enum ViewerSpawnError {
12 NotFound {
14 hint: String,
16 },
17 Io {
19 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
35pub 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#[derive(Debug, Clone, Default)]
53pub struct ViewerResolveEnv<'a> {
54 pub exe_dir: Option<&'a Path>,
56 pub cargo_bin_exe: Option<&'a str>,
58 pub wyvern_viewer_bin: Option<&'a str>,
60 pub path: Option<&'a OsStr>,
62}
63
64pub 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
112pub 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 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
150pub 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
162pub 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}