1use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Context, Result};
14
15pub fn player_bin_name(platform: &str) -> &'static str {
17 match platform {
18 "windows" => "pite-player-windows.exe",
19 "macos" => "pite-player-macos",
20 "linux" => "pite-player-linux",
21 _ => "pite-player",
22 }
23}
24
25pub fn candidate_player_names(platform: &str, _host_os: &str) -> Vec<String> {
27 match platform {
28 "windows" => vec![
29 "pite-player-windows.exe".to_string(),
30 "pite-player.exe".to_string(),
31 ],
32 "macos" => vec!["pite-player-macos".to_string(), "pite-player".to_string()],
33 "linux" => vec!["pite-player-linux".to_string(), "pite-player".to_string()],
34 _ => vec![format!("pite-player-{platform}"), "pite-player".to_string()],
35 }
36}
37
38pub fn player_cache_dir() -> PathBuf {
40 if let Some(dir) = std::env::var_os("PITE_PLAYER_DIR") {
41 return PathBuf::from(dir);
42 }
43 crate::python_bundle::toolchain_dir().join("players")
44}
45
46pub fn player_dir(platform: &str) -> PathBuf {
48 let version =
49 std::env::var("PITE_VERSION").unwrap_or_else(|_| format!("v{}", env!("CARGO_PKG_VERSION")));
50 player_cache_dir().join(version).join(platform)
51}
52
53pub fn ensure_executable(path: &Path) {
55 #[cfg(unix)]
56 {
57 use std::os::unix::fs::PermissionsExt;
58 if let Ok(meta) = std::fs::metadata(path) {
59 let mode = meta.permissions().mode();
60 if mode & 0o111 != 0o111 {
61 let _ =
62 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | 0o755));
63 }
64 }
65 }
66 #[cfg(not(unix))]
67 let _ = path;
68}
69
70pub fn find_cached_player(platform: &str) -> Option<PathBuf> {
72 let version =
73 std::env::var("PITE_VERSION").unwrap_or_else(|_| format!("v{}", env!("CARGO_PKG_VERSION")));
74 let bin_name = player_bin_name(platform);
75
76 let specific = player_cache_dir()
78 .join(&version)
79 .join(platform)
80 .join(bin_name);
81 if specific.is_file() {
82 ensure_executable(&specific);
83 return Some(specific);
84 }
85
86 let version_dir = player_cache_dir().join(&version);
88 for name in candidate_player_names(platform, std::env::consts::OS) {
89 let p = version_dir.join(&name);
90 if p.is_file() {
91 ensure_executable(&p);
92 return Some(p);
93 }
94 }
95
96 for name in candidate_player_names(platform, std::env::consts::OS) {
98 let p = player_cache_dir().join(&name);
99 if p.is_file() {
100 ensure_executable(&p);
101 return Some(p);
102 }
103 }
104
105 None
106}
107
108pub fn player_archive_name(platform: &str, tag: &str) -> String {
110 if platform == "windows" {
111 format!("pite-player-{tag}-windows.zip")
112 } else {
113 format!("pite-player-{tag}-{platform}.tar.gz")
114 }
115}
116
117pub fn player_download_url(platform: &str, tag: &str) -> String {
119 if let Ok(url) = std::env::var("PITE_PLAYER_URL") {
120 return url;
121 }
122 let base = std::env::var("PITE_RELEASE_BASE")
123 .unwrap_or_else(|_| "https://github.com/piteengine/pite/releases/download".to_string());
124 let archive = player_archive_name(platform, tag);
125 format!("{base}/{tag}/{archive}")
126}
127
128pub fn download_and_extract_player(platform: &str) -> Result<PathBuf> {
130 let tag =
131 std::env::var("PITE_VERSION").unwrap_or_else(|_| format!("v{}", env!("CARGO_PKG_VERSION")));
132 let target_dir = player_dir(platform);
133 std::fs::create_dir_all(&target_dir)
134 .with_context(|| format!("cannot create {}", target_dir.display()))?;
135
136 let archive_name = player_archive_name(platform, &tag);
137 let archive_path = target_dir.join(&archive_name);
138 let url = player_download_url(platform, &tag);
139
140 eprintln!("info: downloading player binary for {platform} ({archive_name})...");
141 tracing::info!(platform = %platform, url = %url, "downloading pite-player binary");
142 download_file(&url, &archive_path, platform)?;
143 eprintln!("info: extracting player binary for {platform}...");
144
145 let extract_res = if archive_name.ends_with(".zip") {
146 unpack_zip(&archive_path, &target_dir)
147 } else {
148 unpack_tar(&archive_path, &target_dir)
149 };
150
151 let _ = std::fs::remove_file(&archive_path);
152 extract_res?;
153
154 let bin_name = player_bin_name(platform);
155 let bin_path = target_dir.join(bin_name);
156 if bin_path.is_file() {
157 ensure_executable(&bin_path);
158 return Ok(bin_path);
159 }
160
161 for name in candidate_player_names(platform, std::env::consts::OS) {
162 let p = target_dir.join(&name);
163 if p.is_file() {
164 ensure_executable(&p);
165 return Ok(p);
166 }
167 }
168
169 bail!(
170 "unpacked player archive for {platform} into {}, but could not find {bin_name}",
171 target_dir.display()
172 )
173}
174
175pub fn ensure_player_binary(platform: &str) -> Result<PathBuf> {
178 if let Some(cached) = find_cached_player(platform) {
179 return Ok(cached);
180 }
181 download_and_extract_player(platform)
182}
183
184fn download_file(url: &str, dest: &Path, platform: &str) -> Result<()> {
185 use std::io::IsTerminal;
186 if let Some(path_str) = url.strip_prefix("file://") {
187 let src = Path::new(path_str);
188 std::fs::copy(src, dest)
189 .with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
190 return Ok(());
191 }
192
193 let tool = downloader()?;
194 let _ = std::fs::remove_file(dest);
195 let mut cmd = std::process::Command::new(tool);
196 cmd.args(["--fail", "--location", "--retry", "3", "--retry-delay", "3"]);
197 if std::io::stderr().is_terminal() {
198 cmd.arg("--progress-bar");
199 } else {
200 cmd.args(["--silent", "--show-error"]);
201 }
202 let result = cmd.arg(url).arg("--output").arg(dest).status();
203 match result {
204 Ok(status) if status.success() => Ok(()),
205 Ok(status) => {
206 let _ = std::fs::remove_file(dest);
207 bail!(
208 "download of {url} failed ({status}). Export needs a pite-player binary for \
209 {platform}; retry with network access, or pass --binary with a player binary.",
210 );
211 }
212 Err(e) => {
213 let _ = std::fs::remove_file(dest);
214 bail!(
215 "cannot run {tool} to download {url}: {e}. Export needs a pite-player binary for \
216 {platform}; or pass --binary with a player binary."
217 );
218 }
219 }
220}
221
222fn downloader() -> Result<&'static str> {
223 for tool in ["curl", "curl.exe"] {
224 if std::process::Command::new(tool)
225 .arg("--version")
226 .output()
227 .is_ok_and(|o| o.status.success())
228 {
229 return Ok(tool);
230 }
231 }
232 bail!("curl is required to download pite-player; install curl or pass --binary")
233}
234
235fn unpack_zip(archive: &Path, dest: &Path) -> Result<()> {
236 let file = std::fs::File::open(archive)
237 .with_context(|| format!("cannot open {}", archive.display()))?;
238 let mut zip = zip::ZipArchive::new(file)
239 .with_context(|| format!("cannot read zip {}", archive.display()))?;
240 for i in 0..zip.len() {
241 let mut entry = zip.by_index(i)?;
242 let path = match entry.enclosed_name() {
243 Some(p) => dest.join(p),
244 None => continue,
245 };
246 if entry.name().ends_with('/') {
247 std::fs::create_dir_all(&path)?;
248 } else {
249 if let Some(parent) = path.parent() {
250 std::fs::create_dir_all(parent)?;
251 }
252 let mut outfile = std::fs::File::create(&path)?;
253 std::io::copy(&mut entry, &mut outfile)?;
254 #[cfg(unix)]
255 if let Some(mode) = entry.unix_mode() {
256 use std::os::unix::fs::PermissionsExt;
257 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode));
258 }
259 }
260 }
261 Ok(())
262}
263
264fn unpack_tar(archive: &Path, dest: &Path) -> Result<()> {
265 let tar = if cfg!(windows) { "tar.exe" } else { "tar" };
266 let out = std::process::Command::new(tar)
267 .arg("-xf")
268 .arg(archive)
269 .arg("-C")
270 .arg(dest)
271 .output();
272 match out {
273 Ok(o) if o.status.success() => Ok(()),
274 Ok(o) => bail!(
275 "cannot unpack {} with tar ({}); install tar or pass --binary",
276 archive.display(),
277 o.status
278 ),
279 Err(e) => bail!(
280 "cannot run {tar} to unpack {}: {e}; pass --binary with a player binary",
281 archive.display()
282 ),
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn player_bin_name_resolves_platform() {
292 assert_eq!(player_bin_name("windows"), "pite-player-windows.exe");
293 assert_eq!(player_bin_name("linux"), "pite-player-linux");
294 assert_eq!(player_bin_name("macos"), "pite-player-macos");
295 }
296
297 #[test]
298 fn candidate_player_names_includes_expected() {
299 let cand_win = candidate_player_names("windows", "linux");
300 assert!(cand_win.contains(&"pite-player.exe".to_string()));
301 assert!(cand_win.contains(&"pite-player-windows.exe".to_string()));
302
303 let cand_mac = candidate_player_names("macos", "linux");
304 assert!(cand_mac.contains(&"pite-player-macos".to_string()));
305
306 let cand_lin = candidate_player_names("linux", "windows");
307 assert!(cand_lin.contains(&"pite-player-linux".to_string()));
308 }
309
310 #[test]
311 fn archive_name_formats() {
312 assert_eq!(
313 player_archive_name("windows", "v0.3.0"),
314 "pite-player-v0.3.0-windows.zip"
315 );
316 assert_eq!(
317 player_archive_name("linux", "v0.3.0"),
318 "pite-player-v0.3.0-linux.tar.gz"
319 );
320 assert_eq!(
321 player_archive_name("macos", "v0.3.0"),
322 "pite-player-v0.3.0-macos.tar.gz"
323 );
324 }
325}