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(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 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
369pub 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}