use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
pub const OS_FALLBACK_PARAM: &str = "osFallback";
const PATH_ARG: &str = "{path}";
pub fn take_fallback_flag(params: &mut Value) -> bool {
params
.as_object_mut()
.and_then(|map| map.remove(OS_FALLBACK_PARAM))
.and_then(|value| value.as_bool())
.unwrap_or(true)
}
pub fn is_response_timeout(error: &anyhow::Error) -> bool {
use crate::unity::transport::{PAYLOAD_TIMEOUT_MESSAGE, RESPONSE_TIMEOUT_MESSAGE};
let message = format!("{error:#}");
message.contains(RESPONSE_TIMEOUT_MESSAGE) || message.contains(PAYLOAD_TIMEOUT_MESSAGE)
}
pub fn recover_from_timeout(
error: anyhow::Error,
enabled: bool,
capture: impl FnOnce() -> Result<Value>,
) -> Result<Value> {
if !enabled || !is_response_timeout(&error) {
return Err(error);
}
let editor_error = format!("{error:#}");
tracing::warn!(error = %editor_error, "Unity did not respond; falling back to OS screenshot");
let mut value = capture().map_err(|capture_error| {
error.context(format!(
"OS screenshot fallback also failed: {capture_error:#}"
))
})?;
if let Some(map) = value.as_object_mut() {
map.insert("fallback".to_string(), json!("os"));
map.insert(
"note".to_string(),
json!(format!(
"Unity Editor did not respond ({editor_error}); captured the whole desktop with an OS screenshot instead. \
The Editor main thread may be blocked by a modal dialog. \
Pass \"{OS_FALLBACK_PARAM}\": false to get the timeout error instead."
)),
);
}
Ok(value)
}
pub fn capture_desktop(capture_mode: &str) -> Result<Value> {
let project = crate::core::doctor::resolve_project_path(None);
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let path = output_path(project.as_deref(), millis);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("Failed to create {}: {e}", parent.display()))?;
}
capture_with(&platform_candidates(), &path, capture_mode)
}
fn output_path(project: Option<&Path>, millis: u128) -> PathBuf {
let base = project
.map(|root| root.join(".unity"))
.unwrap_or_else(|| std::env::temp_dir().join("unity-cli"));
base.join("capture").join(format!("image_os_{millis}.png"))
}
struct Candidate {
program: String,
args: Vec<String>,
}
impl Candidate {
fn new(program: &str, args: Vec<&str>) -> Self {
Self {
program: program.to_string(),
args: args.into_iter().map(str::to_string).collect(),
}
}
}
#[cfg(target_os = "macos")]
fn platform_candidates() -> Vec<Candidate> {
vec![Candidate::new(
"screencapture",
vec!["-x", "-t", "png", PATH_ARG],
)]
}
#[cfg(target_os = "windows")]
fn platform_candidates() -> Vec<Candidate> {
const SCRIPT: &str = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; \
$b=[System.Windows.Forms.SystemInformation]::VirtualScreen; \
$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height; \
$g=[System.Drawing.Graphics]::FromImage($bmp); \
$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size); \
$bmp.Save($env:UNITY_CLI_OS_CAPTURE_PATH,[System.Drawing.Imaging.ImageFormat]::Png); \
$g.Dispose(); $bmp.Dispose()";
vec![Candidate::new(
"powershell",
vec!["-NoProfile", "-NonInteractive", "-Command", SCRIPT],
)]
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn platform_candidates() -> Vec<Candidate> {
let wayland = vec![
Candidate::new("grim", vec![PATH_ARG]),
Candidate::new("gnome-screenshot", vec!["-f", PATH_ARG]),
Candidate::new("spectacle", vec!["-b", "-n", "-f", "-o", PATH_ARG]),
];
let x11 = vec![
Candidate::new("import", vec!["-window", "root", PATH_ARG]),
Candidate::new("scrot", vec!["-o", PATH_ARG]),
Candidate::new("maim", vec![PATH_ARG]),
];
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
wayland.into_iter().chain(x11).collect()
} else {
x11.into_iter().chain(wayland).collect()
}
}
fn capture_with(candidates: &[Candidate], path: &Path, capture_mode: &str) -> Result<Value> {
let path_text = path.to_string_lossy().to_string();
let mut failures = Vec::new();
for candidate in candidates {
let _ = std::fs::remove_file(path);
let args = candidate.args.iter().map(|arg| {
if arg == PATH_ARG {
path_text.clone()
} else {
arg.clone()
}
});
let output = std::process::Command::new(&candidate.program)
.args(args)
.env("UNITY_CLI_OS_CAPTURE_PATH", path)
.output();
let failure = match output {
Ok(output) if output.status.success() => match std::fs::read(path) {
Ok(bytes) if !bytes.is_empty() => {
let (width, height) = png_size(&bytes).unzip();
return Ok(json!({
"path": path_text,
"width": width,
"height": height,
"captureMode": capture_mode,
"fileSize": bytes.len(),
"fallbackTool": candidate.program,
"message": "Desktop screenshot captured by the OS (Unity Editor did not respond)"
}));
}
_ => "exited successfully but wrote no image".to_string(),
},
Ok(output) => format!(
"exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
Err(error) => error.to_string(),
};
failures.push(format!("{}: {failure}", candidate.program));
}
Err(anyhow!(
"No OS screenshot tool succeeded ({})",
failures.join("; ")
))
}
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
return None;
}
let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
fn timeout_error() -> anyhow::Error {
anyhow!(crate::unity::transport::RESPONSE_TIMEOUT_MESSAGE)
}
#[test]
fn fallback_flag_defaults_on_and_is_stripped() {
let mut params = json!({"captureMode": "game"});
assert!(take_fallback_flag(&mut params));
let mut params = json!({"captureMode": "game", "osFallback": false});
assert!(!take_fallback_flag(&mut params));
assert!(params.get(OS_FALLBACK_PARAM).is_none());
}
#[test]
fn disabled_fallback_returns_the_timeout_error() {
let result = recover_from_timeout(timeout_error(), false, || {
panic!("OS capture must not run when disabled")
});
let message = format!("{:#}", result.unwrap_err());
assert!(message.contains("Timed out"), "{message}");
}
#[test]
fn non_timeout_errors_are_not_recovered() {
let result = recover_from_timeout(anyhow!("Unity command failed"), true, || {
panic!("OS capture must not run for non-timeout errors")
});
assert!(result.is_err());
}
#[test]
fn timeout_is_recovered_with_os_fallback_marker() {
let value =
recover_from_timeout(timeout_error(), true, || Ok(json!({"path": "/tmp/x.png"})))
.unwrap();
assert_eq!(value["fallback"], "os");
assert_eq!(value["path"], "/tmp/x.png");
assert!(value["note"].as_str().unwrap().contains("did not respond"));
}
#[test]
fn failed_os_capture_keeps_both_errors() {
let error =
recover_from_timeout(timeout_error(), true, || Err(anyhow!("no tool"))).unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("Timed out"), "{message}");
assert!(message.contains("no tool"), "{message}");
}
#[test]
fn detects_daemon_stringified_timeouts() {
let daemon = anyhow!(
"unityd request failed: {}",
crate::unity::transport::RESPONSE_TIMEOUT_MESSAGE
);
assert!(is_response_timeout(&daemon));
assert!(!is_response_timeout(&anyhow!(
"Connection timeout while connecting to Unity"
)));
}
#[cfg(unix)]
#[test]
fn capture_uses_first_available_candidate_and_reads_png_size() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shot.png");
let script = dir.path().join("fake-shot.sh");
std::fs::write(
&script,
"#!/bin/sh\nprintf '\\211PNG\\r\\n\\032\\n\\000\\000\\000\\rIHDR\\000\\000\\000\\003\\000\\000\\000\\002' > \"$1\"\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let candidates = vec![
Candidate::new("definitely-missing-screenshot-tool", vec![PATH_ARG]),
Candidate::new(script.to_str().unwrap(), vec![PATH_ARG]),
];
let value = capture_with(&candidates, &path, "game").unwrap();
assert_eq!(value["path"], path.to_string_lossy().as_ref());
assert_eq!(value["width"], 3);
assert_eq!(value["height"], 2);
assert_eq!(value["captureMode"], "game");
}
#[test]
fn capture_fails_when_no_candidate_exists() {
let dir = tempfile::tempdir().unwrap();
let candidates = vec![Candidate::new(
"definitely-missing-screenshot-tool",
vec![PATH_ARG],
)];
let error = capture_with(&candidates, &dir.path().join("x.png"), "game").unwrap_err();
assert!(format!("{error:#}").contains("definitely-missing-screenshot-tool"));
}
#[test]
fn output_path_follows_bridge_capture_directory() {
let path = output_path(Some(Path::new("/proj")), 42);
assert_eq!(path, PathBuf::from("/proj/.unity/capture/image_os_42.png"));
}
}