Skip to main content

pite_export/
player.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Resolving, caching, and downloading `pite-player` binaries for export.
3//!
4//! When exporting a project, the engine looks for a player binary matching the
5//! target platform next to the engine binary. If one is not present (for example,
6//! when running from a bare `cargo install` or when cross-exporting without having
7//! pre-downloaded other platform bundles), the fallback downloads the platform's
8//! player release archive from GitHub releases and unpacks it into the toolchain
9//! cache (`~/.cache/pite/toolchains/players/` or `PITE_PLAYER_DIR`).
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Context, Result};
14
15/// Player binary file name on its own platform, always carrying the platform name.
16pub 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
25/// Candidate player binary names for `platform`.
26pub 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
38/// Where player binaries live in cache. Never inside the project or repo.
39pub 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
46/// Subdirectory in cache for a specific version and platform.
47pub 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
53/// Ensure that `path` has executable permissions on Unix systems.
54pub 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
70/// Look for an already cached player binary for `platform`.
71pub 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    // 1. Check in platform-specific dir: <cache>/<version>/<platform>/<bin_name>
77    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    // 2. Check if allplatforms was extracted into <cache>/<version>/
87    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    // 3. Check directly in player_cache_dir()
97    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
108/// Release archive filename for `platform` at `tag`.
109pub 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
117/// Download URL for the player archive for `platform` at `tag`.
118pub 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
128/// Download the player archive and unpack it into the cache directory.
129pub 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
175/// Ensure a player binary is available for `platform`, checking cache first and
176/// downloading if absent.
177pub 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}