use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use wyvern_host::ViewerLaunchOptions;
#[derive(Debug)]
pub enum ViewerSpawnError {
NotFound {
hint: String,
},
Io {
message: String,
},
}
impl std::fmt::Display for ViewerSpawnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { hint } => write!(f, "wyvern-viewer not found; {hint}"),
Self::Io { message } => write!(f, "failed to spawn wyvern-viewer: {message}"),
}
}
}
impl std::error::Error for ViewerSpawnError {}
pub fn resolve_viewer_bin() -> Result<PathBuf, ViewerSpawnError> {
if let Some(sibling) = sibling_viewer_bin() {
if is_executable_file(&sibling) {
return Ok(sibling);
}
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_wyvern-viewer") {
let p = PathBuf::from(&path);
if is_executable_file(&p) {
return Ok(p);
}
}
if let Ok(path) = std::env::var("WYVERN_VIEWER_BIN") {
let p = PathBuf::from(&path);
if is_executable_file(&p) {
return Ok(p);
}
if p.is_file() {
return Err(ViewerSpawnError::NotFound {
hint: format!(
"WYVERN_VIEWER_BIN='{path}' exists but is not executable; chmod +x or fix the path"
),
});
}
return Err(ViewerSpawnError::NotFound {
hint: format!(
"WYVERN_VIEWER_BIN='{path}' is not an executable file; install wyvern-viewer or fix the path"
),
});
}
if let Some(path) = which("wyvern-viewer") {
if is_executable_file(&path) {
return Ok(path);
}
}
Err(ViewerSpawnError::NotFound {
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(),
})
}
pub fn spawn_embedded_viewer(
dialog_url: &str,
options: &ViewerLaunchOptions,
) -> Result<Child, ViewerSpawnError> {
let bin = resolve_viewer_bin()?;
let mut cmd = Command::new(&bin);
cmd.arg(dialog_url)
.stdin(Stdio::piped())
.stdout(Stdio::null());
if std::env::var_os("WYVERN_VIEWER_LOG").is_some() {
cmd.stderr(Stdio::inherit());
} else {
cmd.stderr(Stdio::null());
}
if let Some(w) = options.width {
cmd.env("WYVERN_VIEWER_WIDTH", w.to_string());
}
if let Some(h) = options.height {
cmd.env("WYVERN_VIEWER_HEIGHT", h.to_string());
}
if let Some(title) = &options.title {
cmd.env("WYVERN_VIEWER_TITLE", title);
}
cmd.env("WYVERN_DIALOG_URL", dialog_url);
cmd.spawn().map_err(|e| ViewerSpawnError::Io {
message: format!("{}: {e}", bin.display()),
})
}
pub fn request_viewer_exit(child: &mut Child) {
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(b"exit\n");
let _ = stdin.flush();
}
}
pub fn wait_for_viewer_exit(child: &mut Child) {
use std::thread;
use std::time::{Duration, Instant};
request_viewer_exit(child);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => {}
Err(_) => return,
}
if Instant::now() >= deadline {
request_viewer_exit(child);
let _ = child.wait();
return;
}
thread::sleep(Duration::from_millis(50));
}
}
fn sibling_viewer_bin() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
Some(dir.join(viewer_bin_name()))
}
fn viewer_bin_name() -> &'static str {
if cfg!(windows) {
"wyvern-viewer.exe"
} else {
"wyvern-viewer"
}
}
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
#[cfg(windows)]
{
let with_exe = dir.join(format!("{name}.exe"));
if is_executable_file(&with_exe) {
return Some(with_exe);
}
}
}
None
}
fn is_executable_file(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::{OsStr, OsString};
use std::sync::{Mutex, MutexGuard};
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvLock {
_guard: MutexGuard<'static, ()>,
}
impl EnvLock {
fn acquire() -> Self {
Self {
_guard: ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
}
}
}
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
fn resolve_respects_wyvern_viewer_bin_when_no_sibling() {
let _lock = EnvLock::acquire();
let tmp = tempfile::tempdir().expect("tmp");
let fake = tmp.path().join(viewer_bin_name());
std::fs::write(&fake, b"#!/bin/sh\n").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake, perms).unwrap();
}
let _cargo = EnvGuard::remove("CARGO_BIN_EXE_wyvern-viewer");
let _guard = EnvGuard::set("WYVERN_VIEWER_BIN", &fake);
match resolve_viewer_bin() {
Ok(resolved) => {
assert!(
resolved == fake || is_executable_file(&resolved),
"resolved={resolved:?}"
);
}
Err(err) => panic!("expected resolve ok, got {err}"),
}
}
#[test]
fn missing_bin_env_errors_when_override_points_nowhere() {
let _lock = EnvLock::acquire();
let tmp = tempfile::tempdir().expect("tmp");
let missing = tmp.path().join("no-such-viewer");
let _cargo = EnvGuard::remove("CARGO_BIN_EXE_wyvern-viewer");
let _guard = EnvGuard::set("WYVERN_VIEWER_BIN", &missing);
if sibling_viewer_bin()
.as_ref()
.is_some_and(|p| is_executable_file(p))
{
let resolved = resolve_viewer_bin().expect("sibling wins");
assert!(is_executable_file(&resolved));
} else {
let err = resolve_viewer_bin().expect_err("missing");
assert!(matches!(err, ViewerSpawnError::NotFound { .. }));
}
}
#[cfg(unix)]
#[test]
fn non_executable_bin_env_errors() {
let _lock = EnvLock::acquire();
let tmp = tempfile::tempdir().expect("tmp");
let fake = tmp.path().join("not-exec-viewer");
std::fs::write(&fake, b"#!/bin/sh\n").expect("write");
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&fake, perms).unwrap();
assert!(!is_executable_file(&fake));
let _cargo = EnvGuard::remove("CARGO_BIN_EXE_wyvern-viewer");
let _path = EnvGuard::set("PATH", tmp.path());
let _guard = EnvGuard::set("WYVERN_VIEWER_BIN", &fake);
if sibling_viewer_bin()
.as_ref()
.is_some_and(|p| is_executable_file(p))
{
return;
}
let err = resolve_viewer_bin().expect_err("not executable");
match err {
ViewerSpawnError::NotFound { hint } => {
assert!(
hint.contains("not executable") || hint.contains("not an executable"),
"hint={hint}"
);
}
other => panic!("expected NotFound, got {other:?}"),
}
}
}