use crate::{AppLocator, ViewerError, ViewerHandle, ViewerOptions, ViewerResult, ViewerWaitMode};
use html_view_shared::{PROTOCOL_VERSION, ViewerExitReason, ViewerExitStatus, ViewerRequest};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use uuid::Uuid;
struct TempDirGuard {
path: PathBuf,
cleanup_on_drop: bool,
}
impl TempDirGuard {
fn new(path: PathBuf) -> Self {
Self {
path,
cleanup_on_drop: true,
}
}
fn path(&self) -> &PathBuf {
&self.path
}
fn disable_cleanup(&mut self) {
self.cleanup_on_drop = false;
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
if self.cleanup_on_drop {
let _ = fs::remove_dir_all(&self.path);
}
}
}
pub(crate) fn launch_viewer(
options: ViewerOptions,
locator: &dyn AppLocator,
) -> Result<ViewerResult, ViewerError> {
let id = Uuid::new_v4();
let temp_dir_path = std::env::temp_dir().join(format!("html_view_{}", id));
fs::create_dir_all(&temp_dir_path).map_err(|e| {
ViewerError::ConfigWriteFailed(format!(
"Failed to create temporary directory at {}: {}\n\
Suggestion: Check that {} has write permissions and sufficient space",
temp_dir_path.display(),
e,
std::env::temp_dir().display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o700);
fs::set_permissions(&temp_dir_path, permissions).map_err(|e| {
ViewerError::ConfigWriteFailed(format!(
"Failed to set directory permissions on {}: {}",
temp_dir_path.display(),
e
))
})?;
}
let mut temp_dir = TempDirGuard::new(temp_dir_path);
let config_path = temp_dir.path().join("config.json");
let result_path = temp_dir.path().join("result.json");
let command_path = temp_dir.path().join("commands.json");
let response_path = temp_dir.path().join("command_responses.json");
let request = ViewerRequest {
id,
content: options.content,
window: options.window,
behaviour: options.behaviour,
environment: options.environment,
dialog: options.dialog,
command_path: Some(command_path.clone()),
};
let config_json = serde_json::to_string_pretty(&request).map_err(|e| {
ViewerError::SerdeError(format!(
"Failed to serialize viewer configuration: {}\nThis is likely a bug in html_view. Please report at https://github.com/jmg049/HTMLView/issues",
e
))
})?;
fs::write(&config_path, config_json).map_err(|e| {
ViewerError::ConfigWriteFailed(format!(
"Failed to write config file to {}: {}",
config_path.display(),
e
))
})?;
let app_binary = locator.locate_app_binary()?;
let mut cmd = Command::new(&app_binary);
cmd.arg("--config-path")
.arg(&config_path)
.arg("--result-path")
.arg(&result_path);
let mut child = cmd.spawn().map_err(|e| {
ViewerError::SpawnFailed(format!(
"Failed to spawn viewer process at {}: {}\n\
Suggestion: Verify the binary exists and is executable",
app_binary.display(),
e
))
})?;
match options.wait {
ViewerWaitMode::Blocking => {
let exit_status = child.wait()?;
let result = read_result_file(&result_path, id)?;
if !exit_status.success() {
if let ViewerExitReason::Error { .. } = result.reason {
} else {
return Err(ViewerError::SpawnFailed(format!(
"Process exited with code {:?}",
exit_status.code()
)));
}
}
Ok(ViewerResult::Blocking(result))
}
ViewerWaitMode::NonBlocking => {
temp_dir.disable_cleanup();
let handle = ViewerHandle::new(
id,
child,
result_path,
temp_dir.path().clone(),
Some(command_path),
Some(response_path),
);
Ok(ViewerResult::NonBlocking(handle))
}
}
}
fn check_version_compatibility(viewer_version: &str) -> Result<(), ViewerError> {
let library_version = PROTOCOL_VERSION;
let parse_version = |v: &str| -> Result<(u32, u32, u32), ViewerError> {
let parts: Vec<&str> = v.split('.').collect();
if parts.len() != 3 {
return Err(ViewerError::InvalidResponse(format!(
"Invalid version format: {}",
v
)));
}
let major = parts[0].parse::<u32>().map_err(|_| {
ViewerError::InvalidResponse(format!("Invalid major version: {}", parts[0]))
})?;
let minor = parts[1].parse::<u32>().map_err(|_| {
ViewerError::InvalidResponse(format!("Invalid minor version: {}", parts[1]))
})?;
let patch = parts[2].parse::<u32>().map_err(|_| {
ViewerError::InvalidResponse(format!("Invalid patch version: {}", parts[2]))
})?;
Ok((major, minor, patch))
};
let (lib_major, lib_minor, _lib_patch) = parse_version(library_version)?;
let (viewer_major, viewer_minor, _viewer_patch) = parse_version(viewer_version)?;
if viewer_major == 0 && viewer_minor == 0 {
return Err(ViewerError::VersionMismatch {
library: library_version.to_string(),
viewer: viewer_version.to_string(),
suggestion: "Your html_view_app binary is outdated and doesn't report its version.\n\
Please update it with: cargo install html_view_app --force"
.to_string(),
});
}
if lib_major != viewer_major {
let suggestion = if lib_major > viewer_major {
format!(
"Your html_view_app binary is too old.\n\
Please update it with: cargo install html_view_app --version {}.{}.0 --force",
lib_major, lib_minor
)
} else {
format!(
"Your html_view_app binary is too new.\n\
Either downgrade the viewer or update the html_view library to version {}.{}.x",
viewer_major, viewer_minor
)
};
return Err(ViewerError::VersionMismatch {
library: library_version.to_string(),
viewer: viewer_version.to_string(),
suggestion,
});
}
if lib_major == 0 && lib_minor != viewer_minor {
let suggestion = if lib_minor > viewer_minor {
format!(
"Your html_view_app binary is too old for this pre-1.0 library.\n\
Please update it with: cargo install html_view_app --version 0.{}.0 --force",
lib_minor
)
} else {
format!(
"Your html_view_app binary is too new for this pre-1.0 library.\n\
Either downgrade the viewer or update the html_view library to version 0.{}.x",
viewer_minor
)
};
return Err(ViewerError::VersionMismatch {
library: library_version.to_string(),
viewer: viewer_version.to_string(),
suggestion,
});
}
Ok(())
}
fn read_result_file(path: &PathBuf, expected_id: Uuid) -> Result<ViewerExitStatus, ViewerError> {
const MAX_ATTEMPTS: u32 = 10;
const INITIAL_DELAY_MS: u64 = 10;
const MAX_DELAY_MS: u64 = 1000;
let mut delay_ms = INITIAL_DELAY_MS;
let mut last_error = None;
let mut saw_not_found = false;
for attempt in 0..MAX_ATTEMPTS {
match fs::read_to_string(path) {
Ok(data) => {
let status: ViewerExitStatus = serde_json::from_str(&data).map_err(|e| {
ViewerError::InvalidResponse(format!(
"Failed to parse viewer response JSON: {}\nResponse content (first 200 chars): {}",
e,
data.chars().take(200).collect::<String>()
))
})?;
if status.id != expected_id {
return Err(ViewerError::InvalidResponse(format!(
"Result ID mismatch: expected {}, got {}",
expected_id, status.id
)));
}
check_version_compatibility(&status.viewer_version)?;
return Ok(status);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
saw_not_found = true;
}
last_error = Some(e);
if attempt < MAX_ATTEMPTS - 1 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
delay_ms = (delay_ms * 2).min(MAX_DELAY_MS);
}
}
}
}
if saw_not_found {
return Ok(ViewerExitStatus {
id: expected_id,
reason: ViewerExitReason::ClosedByUser,
viewer_version: PROTOCOL_VERSION.to_string(),
});
}
Err(ViewerError::ResultReadFailed(format!(
"Failed to read result file at {} after {} attempts: {}\n\
Suggestion: The viewer process may have crashed. Check system logs or run with devtools enabled.",
path.display(),
MAX_ATTEMPTS,
last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ViewerError;
use std::path::PathBuf;
struct MockAppLocator {
path: Option<PathBuf>,
}
impl AppLocator for MockAppLocator {
fn locate_app_binary(&self) -> Result<PathBuf, ViewerError> {
self.path
.clone()
.ok_or_else(|| ViewerError::BinaryNotFound("Mock binary not found".to_string()))
}
}
#[test]
fn test_launcher_binary_not_found() {
let options = ViewerOptions::inline_html("<h1>Test</h1>");
let locator = MockAppLocator { path: None };
let result = launch_viewer(options, &locator);
assert!(matches!(result, Err(ViewerError::BinaryNotFound(_))));
}
}