Skip to main content

flatland_client_lib/
update.rs

1//! Client binary self-update against the public `latest.json` release manifest.
2
3use std::collections::HashMap;
4use std::fs::{self, File};
5use std::io::{BufReader, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context};
9use flate2::read::GzDecoder;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tracing::{info, warn};
13
14use crate::assets::DEFAULT_FIREBASE_BUCKET;
15
16const DEFAULT_RELEASES_PREFIX: &str = "flatland3/client-releases";
17const BINARIES: &[&str] = &["flatland3", "flatland3-gfx"];
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct UpdateManifest {
21    pub version: String,
22    #[serde(default)]
23    pub published_at: Option<String>,
24    pub artifacts: HashMap<String, Artifact>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct Artifact {
29    pub path: String,
30    pub sha256: String,
31    pub size: u64,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct UpdateAvailable {
36    pub local_version: String,
37    pub remote_version: String,
38    pub platform: String,
39    pub artifact: Artifact,
40    pub archive_url: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AppliedUpdate {
45    pub version: String,
46    pub install_dir: PathBuf,
47}
48
49/// Public HTTPS URL for `latest.json` (same construction as `install.sh`).
50pub fn default_latest_url() -> String {
51    if let Ok(url) = std::env::var("FLATLAND_UPDATE_URL") {
52        if !url.trim().is_empty() {
53            return url;
54        }
55    }
56    let bucket = std::env::var("FLATLAND_ASSETS_BUCKET")
57        .unwrap_or_else(|_| DEFAULT_FIREBASE_BUCKET.to_string());
58    let prefix = std::env::var("FLATLAND_CLIENT_RELEASES_PREFIX")
59        .unwrap_or_else(|_| DEFAULT_RELEASES_PREFIX.to_string());
60    format!(
61        "https://storage.googleapis.com/{}/{}/latest.json",
62        bucket,
63        prefix.trim_end_matches('/')
64    )
65}
66
67pub fn local_version() -> &'static str {
68    env!("CARGO_PKG_VERSION")
69}
70
71/// Mirror `detect_platform()` from `scripts/client-release/install.sh`.
72pub fn platform_tag() -> Option<&'static str> {
73    match (std::env::consts::OS, std::env::consts::ARCH) {
74        ("macos", "aarch64") => Some("macos-aarch64"),
75        ("macos", "x86_64") => Some("macos-x86_64"),
76        ("linux", "x86_64") => Some("linux-x86_64"),
77        ("windows", "x86_64") => Some("windows-x86_64"),
78        _ => None,
79    }
80}
81
82pub fn is_newer(local: &str, remote: &str) -> bool {
83    match (parse_semver(local), parse_semver(remote)) {
84        (Some(l), Some(r)) => r > l,
85        _ => remote != local && !remote.is_empty(),
86    }
87}
88
89fn parse_semver(raw: &str) -> Option<semver::Version> {
90    let trimmed = raw.trim().trim_start_matches('v');
91    semver::Version::parse(trimmed).ok()
92}
93
94/// Directory containing the running client binaries (`current_exe` parent).
95pub fn update_dir() -> anyhow::Result<PathBuf> {
96    let exe = std::env::current_exe().context("resolve current_exe")?;
97    let dir = exe
98        .parent()
99        .ok_or_else(|| anyhow!("current_exe has no parent directory"))?
100        .to_path_buf();
101    Ok(dir)
102}
103
104pub async fn fetch_latest_manifest(
105    client: &reqwest::Client,
106    url: &str,
107) -> anyhow::Result<UpdateManifest> {
108    let resp = client
109        .get(url)
110        .header(
111            reqwest::header::USER_AGENT,
112            format!("flatland-client-lib/{}", local_version()),
113        )
114        .send()
115        .await
116        .with_context(|| format!("GET {url}"))?;
117    let status = resp.status();
118    if !status.is_success() {
119        bail!("GET {url} returned HTTP {status}");
120    }
121    let manifest = resp
122        .json::<UpdateManifest>()
123        .await
124        .context("parse latest.json")?;
125    Ok(manifest)
126}
127
128pub async fn check_for_update(
129    client: &reqwest::Client,
130    local: &str,
131    latest_url: Option<&str>,
132) -> anyhow::Result<Option<UpdateAvailable>> {
133    let url = latest_url
134        .map(str::to_string)
135        .unwrap_or_else(default_latest_url);
136    let platform = platform_tag().ok_or_else(|| {
137        anyhow!(
138            "unsupported platform for self-update ({}-{})",
139            std::env::consts::OS,
140            std::env::consts::ARCH
141        )
142    })?;
143    let manifest = fetch_latest_manifest(client, &url).await?;
144    if !is_newer(local, &manifest.version) {
145        return Ok(None);
146    }
147    let artifact = manifest
148        .artifacts
149        .get(platform)
150        .cloned()
151        .ok_or_else(|| anyhow!("latest.json missing artifact for {platform}"))?;
152    let archive_url = archive_url_for(&url, &artifact.path);
153    Ok(Some(UpdateAvailable {
154        local_version: local.to_string(),
155        remote_version: manifest.version,
156        platform: platform.to_string(),
157        artifact,
158        archive_url,
159    }))
160}
161
162fn archive_url_for(latest_url: &str, artifact_path: &str) -> String {
163    if let Some(base) = latest_url.rsplit_once('/') {
164        format!("{}/{}", base.0, artifact_path.trim_start_matches('/'))
165    } else {
166        artifact_path.to_string()
167    }
168}
169
170/// Download, verify SHA-256, extract, and atomically swap both client binaries.
171pub async fn apply_update(
172    client: &reqwest::Client,
173    offer: &UpdateAvailable,
174) -> anyhow::Result<AppliedUpdate> {
175    let install_dir = update_dir()?;
176    let tmp = tempfile_dir(&install_dir)?;
177    let archive_name = offer
178        .artifact
179        .path
180        .rsplit('/')
181        .next()
182        .unwrap_or("client-archive");
183    let archive_path = tmp.join(archive_name);
184
185    download_file(client, &offer.archive_url, &archive_path).await?;
186    verify_sha256(&archive_path, &offer.artifact.sha256)?;
187
188    let extract_dir = tmp.join("extract");
189    fs::create_dir_all(&extract_dir)?;
190    extract_archive(&archive_path, &extract_dir, &offer.platform)?;
191
192    for name in BINARIES {
193        let src = find_binary(&extract_dir, name)?;
194        swap_binary(&src, &install_dir.join(binary_filename(name)))?;
195    }
196
197    // Best-effort cleanup of the staging directory.
198    let _ = fs::remove_dir_all(&tmp);
199
200    info!(
201        version = %offer.remote_version,
202        dir = %install_dir.display(),
203        "client self-update applied; restart required"
204    );
205
206    Ok(AppliedUpdate {
207        version: offer.remote_version.clone(),
208        install_dir,
209    })
210}
211
212/// Remove `*.old` backups left by a previous successful apply (call on launch).
213pub fn cleanup_old_binaries() {
214    let Ok(dir) = update_dir() else {
215        return;
216    };
217    for name in BINARIES {
218        let old = dir.join(format!("{}.old", binary_filename(name)));
219        if old.exists() {
220            if let Err(err) = fs::remove_file(&old) {
221                warn!(path = %old.display(), error = %err, "failed to remove old client binary");
222            }
223        }
224    }
225}
226
227fn binary_filename(name: &str) -> String {
228    if cfg!(windows) {
229        format!("{name}.exe")
230    } else {
231        name.to_string()
232    }
233}
234
235fn tempfile_dir(install_dir: &Path) -> anyhow::Result<PathBuf> {
236    let stamp = std::time::SystemTime::now()
237        .duration_since(std::time::UNIX_EPOCH)
238        .map(|d| d.as_millis())
239        .unwrap_or(0);
240    let dir = install_dir.join(format!(".flatland-update-{stamp}"));
241    fs::create_dir_all(&dir)?;
242    Ok(dir)
243}
244
245async fn download_file(client: &reqwest::Client, url: &str, dest: &Path) -> anyhow::Result<()> {
246    let mut resp = client
247        .get(url)
248        .header(
249            reqwest::header::USER_AGENT,
250            format!("flatland-client-lib/{}", local_version()),
251        )
252        .send()
253        .await
254        .with_context(|| format!("GET {url}"))?;
255    let status = resp.status();
256    if !status.is_success() {
257        bail!("GET {url} returned HTTP {status}");
258    }
259    let mut file = File::create(dest).with_context(|| format!("create {}", dest.display()))?;
260    while let Some(chunk) = resp.chunk().await.context("read download chunk")? {
261        file.write_all(&chunk)?;
262    }
263    file.flush()?;
264    Ok(())
265}
266
267fn verify_sha256(path: &Path, expected_hex: &str) -> anyhow::Result<()> {
268    let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
269    let mut hasher = Sha256::new();
270    std::io::copy(&mut file, &mut hasher)?;
271    let got = hex::encode(hasher.finalize());
272    let expected = expected_hex.trim().to_ascii_lowercase();
273    if got != expected {
274        bail!(
275            "SHA-256 mismatch for {}: expected {expected}, got {got}",
276            path.display()
277        );
278    }
279    Ok(())
280}
281
282fn extract_archive(archive: &Path, dest: &Path, platform: &str) -> anyhow::Result<()> {
283    if platform.starts_with("windows") || archive.extension().is_some_and(|e| e == "zip") {
284        let file = File::open(archive)?;
285        let mut zip = zip::ZipArchive::new(BufReader::new(file)).context("open zip archive")?;
286        zip.extract(dest).context("extract zip archive")?;
287    } else {
288        let file = File::open(archive)?;
289        let decoder = GzDecoder::new(BufReader::new(file));
290        let mut tar = tar::Archive::new(decoder);
291        tar.unpack(dest).context("extract tar.gz archive")?;
292    }
293    Ok(())
294}
295
296fn find_binary(extract_dir: &Path, name: &str) -> anyhow::Result<PathBuf> {
297    let filename = binary_filename(name);
298    let direct = extract_dir.join(&filename);
299    if direct.is_file() {
300        return Ok(direct);
301    }
302    for entry in walkdir_files(extract_dir)? {
303        if entry
304            .file_name()
305            .and_then(|s| s.to_str())
306            .is_some_and(|n| n == filename)
307        {
308            return Ok(entry);
309        }
310    }
311    bail!("archive missing binary {filename}");
312}
313
314fn walkdir_files(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
315    let mut out = Vec::new();
316    fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
317        for entry in fs::read_dir(dir)? {
318            let entry = entry?;
319            let path = entry.path();
320            if path.is_dir() {
321                walk(&path, out)?;
322            } else if path.is_file() {
323                out.push(path);
324            }
325        }
326        Ok(())
327    }
328    walk(root, &mut out)?;
329    Ok(out)
330}
331
332fn swap_binary(src: &Path, dest: &Path) -> anyhow::Result<()> {
333    let dest_new = PathBuf::from(format!("{}.new", dest.display()));
334    let dest_old = PathBuf::from(format!("{}.old", dest.display()));
335
336    fs::copy(src, &dest_new)
337        .with_context(|| format!("copy {} → {}", src.display(), dest_new.display()))?;
338    #[cfg(unix)]
339    {
340        use std::os::unix::fs::PermissionsExt;
341        let mut perms = fs::metadata(&dest_new)?.permissions();
342        perms.set_mode(0o755);
343        fs::set_permissions(&dest_new, perms)?;
344    }
345
346    if dest.exists() {
347        // Windows: renaming a running .exe is allowed; overwriting is not.
348        // POSIX: rename over the running binary also works.
349        let _ = fs::remove_file(&dest_old);
350        fs::rename(dest, &dest_old).with_context(|| {
351            format!(
352                "rename running binary {} → {}",
353                dest.display(),
354                dest_old.display()
355            )
356        })?;
357    }
358
359    fs::rename(&dest_new, dest).with_context(|| {
360        format!(
361            "install new binary {} → {}",
362            dest_new.display(),
363            dest.display()
364        )
365    })?;
366    Ok(())
367}
368
369/// Whether automatic update checks are enabled (default true).
370pub fn check_updates_enabled(cfg: &crate::ClientConfig) -> bool {
371    if let Ok(raw) = std::env::var("FLATLAND_CHECK_UPDATES") {
372        let v = raw.trim().to_ascii_lowercase();
373        if matches!(v.as_str(), "0" | "false" | "no" | "off") {
374            return false;
375        }
376        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
377            return true;
378        }
379    }
380    cfg.check_updates.unwrap_or(true)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn is_newer_compares_semver() {
389        assert!(is_newer("0.2.31", "0.2.32"));
390        assert!(!is_newer("0.2.32", "0.2.32"));
391        assert!(!is_newer("0.2.32", "0.2.31"));
392        assert!(is_newer("0.2.9", "0.2.10"));
393    }
394
395    #[test]
396    fn platform_tag_is_known_or_none() {
397        if let Some(tag) = platform_tag() {
398            assert!(
399                matches!(
400                    tag,
401                    "macos-aarch64" | "macos-x86_64" | "linux-x86_64" | "windows-x86_64"
402                ),
403                "unexpected tag {tag}"
404            );
405        }
406    }
407
408    #[test]
409    fn manifest_parses_assemble_latest_shape() {
410        let json = r#"{
411          "version": "0.2.32",
412          "published_at": "2026-08-14T12:00:00Z",
413          "artifacts": {
414            "macos-aarch64": {
415              "path": "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
416              "sha256": "abc",
417              "size": 12
418            }
419          }
420        }"#;
421        let m: UpdateManifest = serde_json::from_str(json).unwrap();
422        assert_eq!(m.version, "0.2.32");
423        assert_eq!(
424            m.artifacts["macos-aarch64"].path,
425            "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
426        );
427    }
428
429    #[test]
430    fn archive_url_joins_latest_base() {
431        let url = archive_url_for(
432            "https://storage.googleapis.com/bucket/flatland3/client-releases/latest.json",
433            "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
434        );
435        assert_eq!(
436            url,
437            "https://storage.googleapis.com/bucket/flatland3/client-releases/v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
438        );
439    }
440}