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(
246    client: &reqwest::Client,
247    url: &str,
248    dest: &Path,
249) -> anyhow::Result<()> {
250    let mut resp = client
251        .get(url)
252        .header(
253            reqwest::header::USER_AGENT,
254            format!("flatland-client-lib/{}", local_version()),
255        )
256        .send()
257        .await
258        .with_context(|| format!("GET {url}"))?;
259    let status = resp.status();
260    if !status.is_success() {
261        bail!("GET {url} returned HTTP {status}");
262    }
263    let mut file = File::create(dest).with_context(|| format!("create {}", dest.display()))?;
264    while let Some(chunk) = resp.chunk().await.context("read download chunk")? {
265        file.write_all(&chunk)?;
266    }
267    file.flush()?;
268    Ok(())
269}
270
271fn verify_sha256(path: &Path, expected_hex: &str) -> anyhow::Result<()> {
272    let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
273    let mut hasher = Sha256::new();
274    std::io::copy(&mut file, &mut hasher)?;
275    let got = hex::encode(hasher.finalize());
276    let expected = expected_hex.trim().to_ascii_lowercase();
277    if got != expected {
278        bail!(
279            "SHA-256 mismatch for {}: expected {expected}, got {got}",
280            path.display()
281        );
282    }
283    Ok(())
284}
285
286fn extract_archive(archive: &Path, dest: &Path, platform: &str) -> anyhow::Result<()> {
287    if platform.starts_with("windows") || archive.extension().is_some_and(|e| e == "zip") {
288        let file = File::open(archive)?;
289        let mut zip = zip::ZipArchive::new(BufReader::new(file))
290            .context("open zip archive")?;
291        zip.extract(dest).context("extract zip archive")?;
292    } else {
293        let file = File::open(archive)?;
294        let decoder = GzDecoder::new(BufReader::new(file));
295        let mut tar = tar::Archive::new(decoder);
296        tar.unpack(dest).context("extract tar.gz archive")?;
297    }
298    Ok(())
299}
300
301fn find_binary(extract_dir: &Path, name: &str) -> anyhow::Result<PathBuf> {
302    let filename = binary_filename(name);
303    let direct = extract_dir.join(&filename);
304    if direct.is_file() {
305        return Ok(direct);
306    }
307    for entry in walkdir_files(extract_dir)? {
308        if entry
309            .file_name()
310            .and_then(|s| s.to_str())
311            .is_some_and(|n| n == filename)
312        {
313            return Ok(entry);
314        }
315    }
316    bail!("archive missing binary {filename}");
317}
318
319fn walkdir_files(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
320    let mut out = Vec::new();
321    fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
322        for entry in fs::read_dir(dir)? {
323            let entry = entry?;
324            let path = entry.path();
325            if path.is_dir() {
326                walk(&path, out)?;
327            } else if path.is_file() {
328                out.push(path);
329            }
330        }
331        Ok(())
332    }
333    walk(root, &mut out)?;
334    Ok(out)
335}
336
337fn swap_binary(src: &Path, dest: &Path) -> anyhow::Result<()> {
338    let dest_new = PathBuf::from(format!("{}.new", dest.display()));
339    let dest_old = PathBuf::from(format!("{}.old", dest.display()));
340
341    fs::copy(src, &dest_new).with_context(|| {
342        format!(
343            "copy {} → {}",
344            src.display(),
345            dest_new.display()
346        )
347    })?;
348    #[cfg(unix)]
349    {
350        use std::os::unix::fs::PermissionsExt;
351        let mut perms = fs::metadata(&dest_new)?.permissions();
352        perms.set_mode(0o755);
353        fs::set_permissions(&dest_new, perms)?;
354    }
355
356    if dest.exists() {
357        // Windows: renaming a running .exe is allowed; overwriting is not.
358        // POSIX: rename over the running binary also works.
359        let _ = fs::remove_file(&dest_old);
360        fs::rename(dest, &dest_old).with_context(|| {
361            format!(
362                "rename running binary {} → {}",
363                dest.display(),
364                dest_old.display()
365            )
366        })?;
367    }
368
369    fs::rename(&dest_new, dest).with_context(|| {
370        format!(
371            "install new binary {} → {}",
372            dest_new.display(),
373            dest.display()
374        )
375    })?;
376    Ok(())
377}
378
379/// Whether automatic update checks are enabled (default true).
380pub fn check_updates_enabled(cfg: &crate::ClientConfig) -> bool {
381    if let Ok(raw) = std::env::var("FLATLAND_CHECK_UPDATES") {
382        let v = raw.trim().to_ascii_lowercase();
383        if matches!(v.as_str(), "0" | "false" | "no" | "off") {
384            return false;
385        }
386        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
387            return true;
388        }
389    }
390    cfg.check_updates.unwrap_or(true)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn is_newer_compares_semver() {
399        assert!(is_newer("0.2.31", "0.2.32"));
400        assert!(!is_newer("0.2.32", "0.2.32"));
401        assert!(!is_newer("0.2.32", "0.2.31"));
402        assert!(is_newer("0.2.9", "0.2.10"));
403    }
404
405    #[test]
406    fn platform_tag_is_known_or_none() {
407        if let Some(tag) = platform_tag() {
408            assert!(
409                matches!(
410                    tag,
411                    "macos-aarch64" | "macos-x86_64" | "linux-x86_64" | "windows-x86_64"
412                ),
413                "unexpected tag {tag}"
414            );
415        }
416    }
417
418    #[test]
419    fn manifest_parses_assemble_latest_shape() {
420        let json = r#"{
421          "version": "0.2.32",
422          "published_at": "2026-08-14T12:00:00Z",
423          "artifacts": {
424            "macos-aarch64": {
425              "path": "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
426              "sha256": "abc",
427              "size": 12
428            }
429          }
430        }"#;
431        let m: UpdateManifest = serde_json::from_str(json).unwrap();
432        assert_eq!(m.version, "0.2.32");
433        assert_eq!(
434            m.artifacts["macos-aarch64"].path,
435            "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
436        );
437    }
438
439    #[test]
440    fn archive_url_joins_latest_base() {
441        let url = archive_url_for(
442            "https://storage.googleapis.com/bucket/flatland3/client-releases/latest.json",
443            "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
444        );
445        assert_eq!(
446            url,
447            "https://storage.googleapis.com/bucket/flatland3/client-releases/v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
448        );
449    }
450}