use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde_json::Value;
use crate::steps::{github_get_text, probe};
use crate::ui;
const ROBLOX_STUD_SCALE: f64 = 0.28;
pub fn download_latest_plugin_zip(github_repo: &str) -> Result<PathBuf> {
let api_url = format!("https://api.github.com/repos/{github_repo}/releases/latest");
let body = github_get_text(&api_url)?;
let release: Value = serde_json::from_str(&body).context("failed to parse GitHub release JSON")?;
let assets = release["assets"].as_array().context("release response had no assets array")?;
let asset = assets
.iter()
.find(|a| a["name"].as_str().is_some_and(|n| n.ends_with(".zip")))
.context("no .zip asset found in latest Roblox Blender plugin release")?;
let name = asset["name"].as_str().context("asset has no name")?;
let download_url = asset["browser_download_url"]
.as_str()
.context("asset has no browser_download_url")?;
let bytes = ureq::get(download_url)
.header("User-Agent", "rproj")
.call()
.with_context(|| format!("failed to download {download_url}"))?
.body_mut()
.read_to_vec()
.context("failed to read plugin zip body")?;
let dest = std::env::temp_dir().join(name);
fs::write(&dest, bytes)?;
Ok(dest)
}
pub fn install_addon(zip_path: &Path) -> Result<()> {
let zip_path_str = zip_path.to_string_lossy().replace('\\', "/");
let script = format!(
r#"
import bpy, os, zipfile
zip_path = r"{zip_path_str}"
names = zipfile.ZipFile(zip_path).namelist()
candidates = sorted({{
n.split('/')[0] for n in names
if n.strip('/') and not n.startswith('__MACOSX') and '.' not in n.split('/')[0]
}})
module = candidates[0] if candidates else None
addons_dir = bpy.utils.user_resource('SCRIPTS', path="addons")
already_installed = bool(module) and (
os.path.isdir(os.path.join(addons_dir, module)) or os.path.isfile(os.path.join(addons_dir, module + ".py"))
)
if already_installed:
print("RPROJ_ALREADY_INSTALLED:" + module)
else:
bpy.ops.preferences.addon_install(filepath=zip_path)
if module:
try:
bpy.ops.preferences.addon_enable(module=module)
print("RPROJ_ENABLED:" + module)
except Exception as e:
print("RPROJ_ENABLE_FAILED:" + module + ":" + str(e))
else:
print("RPROJ_UNKNOWN_MODULE")
bpy.ops.wm.save_userpref()
"#
);
let stdout = run_headless_script(&script)?;
if let Some(module) = stdout.lines().find_map(|l| l.strip_prefix("RPROJ_ALREADY_INSTALLED:")) {
ui::ok(&format!("Blender add-on already installed ({module})"));
}
Ok(())
}
pub fn print_account_link_instructions() {
ui::warn("Blender add-on needs two one-time manual steps (they need a UI, so can't be scripted)");
ui::detail(
"1. Blender > Edit > Preferences > Add-ons > find \"Roblox\", expand it\n\
2. In a 3D viewport press N > \"Roblox\" tab > Install Dependencies, then restart\n\
3. Follow the sign-in prompt to connect your account via Open Cloud\n\
Docs: https://create.roblox.com/docs/art/modeling/roblox-blender-plugin",
);
}
pub fn scaffold_starter_scene(project_dir: &Path) -> Result<()> {
let blender_dir = project_dir.join("blender");
let dest = blender_dir.join("scene.blend");
if dest.exists() {
ui::ok("blender/scene.blend already exists");
return Ok(());
}
fs::create_dir_all(&blender_dir)?;
let dest_str = dest.to_string_lossy().replace('\\', "/");
let script = format!(
r#"
import bpy
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.scene.unit_settings.system = 'METRIC'
bpy.context.scene.unit_settings.scale_length = {ROBLOX_STUD_SCALE}
bpy.ops.wm.save_as_mainfile(filepath=r"{dest_str}")
"#
);
run_headless_script(&script)?;
ui::ok("scaffolded blender/scene.blend");
Ok(())
}
fn run_headless_script(script: &str) -> Result<String> {
let script_path = std::env::temp_dir().join(format!("rproj-blender-{}.py", std::process::id()));
fs::write(&script_path, script)?;
let blender_exe = locate_blender_exe()?;
ui::command(
&blender_exe.to_string_lossy(),
&["--background", "--python", &script_path.to_string_lossy()],
);
let output = std::process::Command::new(&blender_exe)
.args(["--background", "--python", &script_path.to_string_lossy()])
.output()
.with_context(|| format!("failed to spawn `{}`", blender_exe.display()));
let _ = fs::remove_file(&script_path);
let output = output?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() || ui::is_verbose() {
ui::passthrough(&stdout, &stderr);
}
if !output.status.success() {
bail!(
"blender exited with {} (see output above - if it just says \
\"Python script failed, check the message in the system console\", \
the addon zip may be corrupt or Blender is older than the 3.2+ this \
plugin requires)",
output.status
);
}
Ok(stdout)
}
fn locate_blender_exe() -> Result<PathBuf> {
if probe("blender", &["--version"]) {
return Ok(PathBuf::from("blender"));
}
let program_files = std::env::var_os("ProgramFiles")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
let base = program_files.join("Blender Foundation");
let mut candidates: Vec<PathBuf> = fs::read_dir(&base)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path().join("blender.exe"))
.filter(|p| p.is_file())
.collect();
candidates.sort();
candidates.pop().with_context(|| {
format!(
"blender isn't on PATH and no install was found under {} - is Blender installed?",
base.display()
)
})
}