use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufReader, Write};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{info, warn};
use crate::assets::DEFAULT_FIREBASE_BUCKET;
const DEFAULT_RELEASES_PREFIX: &str = "flatland3/client-releases";
const BINARIES: &[&str] = &["flatland3", "flatland3-gfx"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateManifest {
pub version: String,
#[serde(default)]
pub published_at: Option<String>,
pub artifacts: HashMap<String, Artifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Artifact {
pub path: String,
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAvailable {
pub local_version: String,
pub remote_version: String,
pub platform: String,
pub artifact: Artifact,
pub archive_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedUpdate {
pub version: String,
pub install_dir: PathBuf,
}
pub fn default_latest_url() -> String {
if let Ok(url) = std::env::var("FLATLAND_UPDATE_URL") {
if !url.trim().is_empty() {
return url;
}
}
let bucket = std::env::var("FLATLAND_ASSETS_BUCKET")
.unwrap_or_else(|_| DEFAULT_FIREBASE_BUCKET.to_string());
let prefix = std::env::var("FLATLAND_CLIENT_RELEASES_PREFIX")
.unwrap_or_else(|_| DEFAULT_RELEASES_PREFIX.to_string());
format!(
"https://storage.googleapis.com/{}/{}/latest.json",
bucket,
prefix.trim_end_matches('/')
)
}
pub fn local_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn platform_tag() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Some("macos-aarch64"),
("macos", "x86_64") => Some("macos-x86_64"),
("linux", "x86_64") => Some("linux-x86_64"),
("windows", "x86_64") => Some("windows-x86_64"),
_ => None,
}
}
pub fn is_newer(local: &str, remote: &str) -> bool {
match (parse_semver(local), parse_semver(remote)) {
(Some(l), Some(r)) => r > l,
_ => remote != local && !remote.is_empty(),
}
}
fn parse_semver(raw: &str) -> Option<semver::Version> {
let trimmed = raw.trim().trim_start_matches('v');
semver::Version::parse(trimmed).ok()
}
pub fn update_dir() -> anyhow::Result<PathBuf> {
let exe = std::env::current_exe().context("resolve current_exe")?;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current_exe has no parent directory"))?
.to_path_buf();
Ok(dir)
}
pub async fn fetch_latest_manifest(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<UpdateManifest> {
let resp = client
.get(url)
.header(
reqwest::header::USER_AGENT,
format!("flatland-client-lib/{}", local_version()),
)
.send()
.await
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!("GET {url} returned HTTP {status}");
}
let manifest = resp
.json::<UpdateManifest>()
.await
.context("parse latest.json")?;
Ok(manifest)
}
pub async fn check_for_update(
client: &reqwest::Client,
local: &str,
latest_url: Option<&str>,
) -> anyhow::Result<Option<UpdateAvailable>> {
let url = latest_url
.map(str::to_string)
.unwrap_or_else(default_latest_url);
let platform = platform_tag().ok_or_else(|| {
anyhow!(
"unsupported platform for self-update ({}-{})",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let manifest = fetch_latest_manifest(client, &url).await?;
if !is_newer(local, &manifest.version) {
return Ok(None);
}
let artifact = manifest
.artifacts
.get(platform)
.cloned()
.ok_or_else(|| anyhow!("latest.json missing artifact for {platform}"))?;
let archive_url = archive_url_for(&url, &artifact.path);
Ok(Some(UpdateAvailable {
local_version: local.to_string(),
remote_version: manifest.version,
platform: platform.to_string(),
artifact,
archive_url,
}))
}
fn archive_url_for(latest_url: &str, artifact_path: &str) -> String {
if let Some(base) = latest_url.rsplit_once('/') {
format!("{}/{}", base.0, artifact_path.trim_start_matches('/'))
} else {
artifact_path.to_string()
}
}
pub async fn apply_update(
client: &reqwest::Client,
offer: &UpdateAvailable,
) -> anyhow::Result<AppliedUpdate> {
let install_dir = update_dir()?;
let tmp = tempfile_dir(&install_dir)?;
let archive_name = offer
.artifact
.path
.rsplit('/')
.next()
.unwrap_or("client-archive");
let archive_path = tmp.join(archive_name);
download_file(client, &offer.archive_url, &archive_path).await?;
verify_sha256(&archive_path, &offer.artifact.sha256)?;
let extract_dir = tmp.join("extract");
fs::create_dir_all(&extract_dir)?;
extract_archive(&archive_path, &extract_dir, &offer.platform)?;
for name in BINARIES {
let src = find_binary(&extract_dir, name)?;
swap_binary(&src, &install_dir.join(binary_filename(name)))?;
}
let _ = fs::remove_dir_all(&tmp);
info!(
version = %offer.remote_version,
dir = %install_dir.display(),
"client self-update applied; restart required"
);
Ok(AppliedUpdate {
version: offer.remote_version.clone(),
install_dir,
})
}
pub fn cleanup_old_binaries() {
let Ok(dir) = update_dir() else {
return;
};
for name in BINARIES {
let old = dir.join(format!("{}.old", binary_filename(name)));
if old.exists() {
if let Err(err) = fs::remove_file(&old) {
warn!(path = %old.display(), error = %err, "failed to remove old client binary");
}
}
}
}
fn binary_filename(name: &str) -> String {
if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
}
}
fn tempfile_dir(install_dir: &Path) -> anyhow::Result<PathBuf> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let dir = install_dir.join(format!(".flatland-update-{stamp}"));
fs::create_dir_all(&dir)?;
Ok(dir)
}
async fn download_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
) -> anyhow::Result<()> {
let mut resp = client
.get(url)
.header(
reqwest::header::USER_AGENT,
format!("flatland-client-lib/{}", local_version()),
)
.send()
.await
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!("GET {url} returned HTTP {status}");
}
let mut file = File::create(dest).with_context(|| format!("create {}", dest.display()))?;
while let Some(chunk) = resp.chunk().await.context("read download chunk")? {
file.write_all(&chunk)?;
}
file.flush()?;
Ok(())
}
fn verify_sha256(path: &Path, expected_hex: &str) -> anyhow::Result<()> {
let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
let got = hex::encode(hasher.finalize());
let expected = expected_hex.trim().to_ascii_lowercase();
if got != expected {
bail!(
"SHA-256 mismatch for {}: expected {expected}, got {got}",
path.display()
);
}
Ok(())
}
fn extract_archive(archive: &Path, dest: &Path, platform: &str) -> anyhow::Result<()> {
if platform.starts_with("windows") || archive.extension().is_some_and(|e| e == "zip") {
let file = File::open(archive)?;
let mut zip = zip::ZipArchive::new(BufReader::new(file))
.context("open zip archive")?;
zip.extract(dest).context("extract zip archive")?;
} else {
let file = File::open(archive)?;
let decoder = GzDecoder::new(BufReader::new(file));
let mut tar = tar::Archive::new(decoder);
tar.unpack(dest).context("extract tar.gz archive")?;
}
Ok(())
}
fn find_binary(extract_dir: &Path, name: &str) -> anyhow::Result<PathBuf> {
let filename = binary_filename(name);
let direct = extract_dir.join(&filename);
if direct.is_file() {
return Ok(direct);
}
for entry in walkdir_files(extract_dir)? {
if entry
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n == filename)
{
return Ok(entry);
}
}
bail!("archive missing binary {filename}");
}
fn walkdir_files(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut out = Vec::new();
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
walk(root, &mut out)?;
Ok(out)
}
fn swap_binary(src: &Path, dest: &Path) -> anyhow::Result<()> {
let dest_new = PathBuf::from(format!("{}.new", dest.display()));
let dest_old = PathBuf::from(format!("{}.old", dest.display()));
fs::copy(src, &dest_new).with_context(|| {
format!(
"copy {} → {}",
src.display(),
dest_new.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&dest_new)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&dest_new, perms)?;
}
if dest.exists() {
let _ = fs::remove_file(&dest_old);
fs::rename(dest, &dest_old).with_context(|| {
format!(
"rename running binary {} → {}",
dest.display(),
dest_old.display()
)
})?;
}
fs::rename(&dest_new, dest).with_context(|| {
format!(
"install new binary {} → {}",
dest_new.display(),
dest.display()
)
})?;
Ok(())
}
pub fn check_updates_enabled(cfg: &crate::ClientConfig) -> bool {
if let Ok(raw) = std::env::var("FLATLAND_CHECK_UPDATES") {
let v = raw.trim().to_ascii_lowercase();
if matches!(v.as_str(), "0" | "false" | "no" | "off") {
return false;
}
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
}
cfg.check_updates.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_compares_semver() {
assert!(is_newer("0.2.31", "0.2.32"));
assert!(!is_newer("0.2.32", "0.2.32"));
assert!(!is_newer("0.2.32", "0.2.31"));
assert!(is_newer("0.2.9", "0.2.10"));
}
#[test]
fn platform_tag_is_known_or_none() {
if let Some(tag) = platform_tag() {
assert!(
matches!(
tag,
"macos-aarch64" | "macos-x86_64" | "linux-x86_64" | "windows-x86_64"
),
"unexpected tag {tag}"
);
}
}
#[test]
fn manifest_parses_assemble_latest_shape() {
let json = r#"{
"version": "0.2.32",
"published_at": "2026-08-14T12:00:00Z",
"artifacts": {
"macos-aarch64": {
"path": "v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
"sha256": "abc",
"size": 12
}
}
}"#;
let m: UpdateManifest = serde_json::from_str(json).unwrap();
assert_eq!(m.version, "0.2.32");
assert_eq!(
m.artifacts["macos-aarch64"].path,
"v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
);
}
#[test]
fn archive_url_joins_latest_base() {
let url = archive_url_for(
"https://storage.googleapis.com/bucket/flatland3/client-releases/latest.json",
"v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz",
);
assert_eq!(
url,
"https://storage.googleapis.com/bucket/flatland3/client-releases/v0.2.32/flatland3-client-0.2.32-macos-aarch64.tar.gz"
);
}
}