1use 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
49pub 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
71pub 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
94pub 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
170pub 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 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
212pub 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 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
379pub 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}