use std::fs::{self, File, OpenOptions};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::io::{create_dir_all, list_directory};
use crate::paths::{join_path, usg_cache_dir};
const COMPLETE_MARKER: &str = ".complete";
const PEER_WAIT: Duration = Duration::from_secs(60);
pub fn ensure_extracted_for_package(
unity_path: &str,
unity_version: &str,
package_name: &str,
) -> Option<String> {
let cache_root = usg_cache_dir(unity_version);
create_dir_all(&cache_root);
let target = format!("{}/{}", cache_root, package_name);
let complete = format!("{}/{}", target, COMPLETE_MARKER);
if Path::new(&complete).exists() {
return Some(target);
}
let editor_dir = join_path(
unity_path,
"Unity.app/Contents/Resources/PackageManager/Editor",
);
if !Path::new(&editor_dir).exists() {
return None;
}
let prefix = format!("{}-", package_name);
let tgz_name = list_directory(&editor_dir).into_iter().find(|entry| {
entry.ends_with(".tgz")
&& entry.starts_with(&prefix)
&& entry[prefix.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
})?;
let tgz_path = format!("{}/{}", editor_dir, tgz_name);
if let Err(e) = extract_one(&tgz_path, &cache_root) {
tracing::warn!(
"package_cache: failed to extract {} for {}: {}",
tgz_path,
package_name,
e
);
return None;
}
Some(target)
}
fn extract_one(tgz_path: &str, cache_root: &str) -> io::Result<()> {
let pkg_name = peek_package_name(Path::new(tgz_path))?;
let target = Path::new(cache_root).join(&pkg_name);
let complete = target.join(COMPLETE_MARKER);
if complete.exists() {
return Ok(());
}
let lock_path = Path::new(cache_root).join(format!(".lock.{}", pkg_name));
let _lock_guard = match acquire_lock(&lock_path, &complete)? {
LockOutcome::Acquired(g) => g,
LockOutcome::PeerCompleted => return Ok(()),
};
if complete.exists() {
return Ok(());
}
if target.exists() {
fs::remove_dir_all(&target)?;
}
fs::create_dir_all(&target)?;
extract_tarball(Path::new(tgz_path), &target)?;
File::create(&complete)?;
Ok(())
}
enum LockOutcome {
Acquired(LockGuard),
PeerCompleted,
}
struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn acquire_lock(lock_path: &Path, complete_marker: &Path) -> io::Result<LockOutcome> {
let deadline = Instant::now() + PEER_WAIT;
loop {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(_) => {
return Ok(LockOutcome::Acquired(LockGuard {
path: lock_path.to_path_buf(),
}));
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if complete_marker.exists() {
return Ok(LockOutcome::PeerCompleted);
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"package_cache: peer holding {} did not finish within {:?}; remove the file manually if stale",
lock_path.display(),
PEER_WAIT
),
));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => return Err(e),
}
}
}
fn peek_package_name(tgz: &Path) -> io::Result<String> {
let f = File::open(tgz)?;
let gz = flate2::read::GzDecoder::new(f);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
let is_manifest = path == Path::new("package/package.json")
|| path == Path::new("package.json");
if !is_manifest {
continue;
}
let mut content = String::new();
entry.read_to_string(&mut content)?;
let v: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if let Some(name) = v.get("name").and_then(|s| s.as_str()) {
return Ok(name.to_string());
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no package.json in {}", tgz.display()),
))
}
fn extract_tarball(tgz: &Path, dest: &Path) -> io::Result<()> {
let f = File::open(tgz)?;
let gz = flate2::read::GzDecoder::new(f);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
let stripped = match path.strip_prefix("package") {
Ok(p) => p.to_path_buf(),
Err(_) => path.clone(),
};
if stripped.as_os_str().is_empty() {
continue;
}
if stripped.is_absolute()
|| stripped
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
continue;
}
let header_type = entry.header().entry_type();
if matches!(header_type, tar::EntryType::Symlink | tar::EntryType::Link) {
continue;
}
let target = dest.join(&stripped);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
entry.unpack(&target)?;
}
Ok(())
}