1use std::collections::BTreeMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10pub const DEFAULT_FIREBASE_BUCKET: &str = "flatland-8911e.appspot.com";
11pub const ASSETS_INDEX_OBJECT: &str = "flatland3/client-assets/latest.json";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct AssetFileEntry {
15 pub sha256: String,
16 pub size: u64,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct AssetBundleIndex {
21 pub publish_rev: u64,
23 pub published_at: String,
24 pub files: BTreeMap<String, AssetFileEntry>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct LocalAssetState {
29 pub publish_rev: u64,
30 pub sprites_dir: PathBuf,
31}
32
33pub fn assets_root_dir() -> anyhow::Result<PathBuf> {
34 let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
35 Ok(base.join(".flatland3").join("assets"))
36}
37
38pub fn local_state_path() -> anyhow::Result<PathBuf> {
39 Ok(assets_root_dir()?.join("state.json"))
40}
41
42pub fn read_local_state() -> Option<LocalAssetState> {
43 let path = local_state_path().ok()?;
44 let bytes = std::fs::read(path).ok()?;
45 serde_json::from_slice(&bytes).ok()
46}
47
48pub fn write_local_state(state: &LocalAssetState) -> anyhow::Result<()> {
49 let path = local_state_path()?;
50 if let Some(parent) = path.parent() {
51 std::fs::create_dir_all(parent)?;
52 }
53 std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
54 Ok(())
55}
56
57pub fn sprites_dir_for_rev(publish_rev: u64) -> anyhow::Result<PathBuf> {
58 Ok(assets_root_dir()?.join(format!("rev-{publish_rev}")))
59}
60
61pub fn current_symlink_path() -> anyhow::Result<PathBuf> {
62 Ok(assets_root_dir()?.join("current"))
63}
64
65pub fn activate_rev_dir(rev_dir: &Path) -> anyhow::Result<()> {
66 let link = current_symlink_path()?;
67 if link.exists() {
68 std::fs::remove_file(&link).or_else(|_| std::fs::remove_dir_all(&link))?;
69 }
70 #[cfg(unix)]
71 {
72 std::os::unix::fs::symlink(rev_dir, &link)?;
73 }
74 #[cfg(not(unix))]
75 {
76 std::fs::write(link.with_extension("path"), rev_dir)?;
77 std::fs::copy(
78 rev_dir.join("manifest.yaml"),
79 link.join("manifest.yaml"),
80 )
81 .ok();
82 }
83 Ok(())
84}
85
86pub fn find_repo_sprites_dir() -> Option<PathBuf> {
87 let mut dir = std::env::current_dir().ok()?;
88 for _ in 0..8 {
89 let candidate = dir.join("assets/gfx/sprites");
90 if candidate.join("manifest.yaml").is_file() {
91 return Some(candidate);
92 }
93 if !dir.pop() {
94 break;
95 }
96 }
97 None
98}
99
100pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
102 match (repo_rev, local_rev) {
103 (Some(rr), Some(lr)) => rr >= lr,
104 (Some(_), None) => true,
105 _ => false,
106 }
107}
108
109pub fn resolve_sprites_dir() -> Option<PathBuf> {
116 if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
117 let path = PathBuf::from(p);
118 if path.join("manifest.yaml").is_file() {
119 return Some(path);
120 }
121 }
122
123 let repo = find_repo_sprites_dir();
124 let repo_rev = read_repo_publish_rev();
125 let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
126 let local_rev = local.as_ref().map(|s| s.publish_rev);
127
128 if repo_sprites_outrank_cache(repo_rev, local_rev) {
129 if let Some(repo_dir) = repo {
130 return Some(repo_dir);
131 }
132 }
133 if let Some(local) = local {
134 return Some(local.sprites_dir);
135 }
136 if let Some(repo_dir) = repo {
137 return Some(repo_dir);
138 }
139
140 let current = current_symlink_path().ok()?;
141 if current.join("manifest.yaml").is_file() {
142 return Some(current);
143 }
144 None
145}
146
147pub fn assets_index_url() -> String {
148 std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
149 firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
150 })
151}
152
153pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
154 let encoded = urlencoding_encode(object_path);
155 format!(
156 "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
157 )
158}
159
160pub fn assets_storage_prefix() -> String {
161 std::env::var("FLATLAND_ASSETS_PREFIX")
162 .unwrap_or_else(|_| "flatland3/client-assets".to_string())
163}
164
165pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
166 format!("{prefix}/rev-{publish_rev}/{relative}")
167}
168
169pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
170 firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
171}
172
173fn urlencoding_encode(path: &str) -> String {
174 path.bytes()
175 .map(|b| match b {
176 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
177 (b as char).to_string()
178 }
179 _ => format!("%{b:02X}"),
180 })
181 .collect()
182}
183
184pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
185 let mut file = std::fs::File::open(path)?;
186 let mut hasher = Sha256::new();
187 let mut buf = [0u8; 8192];
188 loop {
189 let n = file.read(&mut buf)?;
190 if n == 0 {
191 break;
192 }
193 hasher.update(&buf[..n]);
194 }
195 Ok(hex::encode(hasher.finalize()))
196}
197
198pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
200 let mut files = BTreeMap::new();
201 walk_sprites_dir(sprites_dir, sprites_dir, &mut files)?;
202 Ok(AssetBundleIndex {
203 publish_rev,
204 published_at: published_at.to_string(),
205 files,
206 })
207}
208
209fn walk_sprites_dir(
210 root: &Path,
211 dir: &Path,
212 files: &mut BTreeMap<String, AssetFileEntry>,
213) -> anyhow::Result<()> {
214 for entry in std::fs::read_dir(dir)? {
215 let entry = entry?;
216 let path = entry.path();
217 if path.is_dir() {
218 let name = entry.file_name();
219 if name == "previews" {
220 continue;
221 }
222 walk_sprites_dir(root, &path, files)?;
223 continue;
224 }
225 let rel = path
226 .strip_prefix(root)?
227 .to_string_lossy()
228 .replace('\\', "/");
229 if rel.starts_with('.') || rel.contains("/.") {
230 continue;
231 }
232 if rel.ends_with(".html") {
233 continue;
234 }
235 let meta = std::fs::metadata(&path)?;
236 files.insert(
237 rel,
238 AssetFileEntry {
239 sha256: sha256_file(&path)?,
240 size: meta.len(),
241 },
242 );
243 }
244 Ok(())
245}
246
247pub struct AssetSyncOptions {
248 pub index_url: String,
249 pub storage_prefix: String,
250 pub target_rev: Option<u64>,
251 pub quiet: bool,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum AssetSyncKind {
257 Remote,
259 LocalCacheNoRemote,
261 RepoDevNoRemote,
263}
264
265#[derive(Debug, Clone)]
266pub struct AssetSyncResult {
267 pub state: LocalAssetState,
268 pub kind: AssetSyncKind,
269}
270
271impl Default for AssetSyncOptions {
272 fn default() -> Self {
273 Self {
274 index_url: assets_index_url(),
275 storage_prefix: assets_storage_prefix(),
276 target_rev: None,
277 quiet: false,
278 }
279 }
280}
281
282pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
283 if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_none() {
286 if let Some(repo_dir) = find_repo_sprites_dir() {
287 if let Some(publish_rev) = read_repo_publish_rev() {
288 if opts.target_rev.map_or(true, |t| publish_rev >= t) {
289 if !opts.quiet {
290 println!(
291 "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
292 repo_dir.display()
293 );
294 }
295 return Ok(AssetSyncResult {
296 state: LocalAssetState {
297 publish_rev,
298 sprites_dir: repo_dir,
299 },
300 kind: AssetSyncKind::RepoDevNoRemote,
301 });
302 }
303 }
304 }
305 }
306
307 let client = reqwest::Client::builder()
308 .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
309 .connect_timeout(std::time::Duration::from_secs(5))
310 .timeout(std::time::Duration::from_secs(30))
311 .build()?;
312 let response = client.get(&opts.index_url).send().await?;
313 if response.status() == reqwest::StatusCode::NOT_FOUND {
314 return sync_without_remote_index(opts);
315 }
316 let index: AssetBundleIndex = response.error_for_status()?.json().await?;
317 sync_from_index(&client, opts, index).await
318}
319
320fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
321 if let Some(local) = read_local_state() {
322 if local.sprites_dir.join("manifest.yaml").is_file() {
323 if !opts.quiet {
324 println!(
325 "Remote latest.json not found; using cached publish rev {}.",
326 local.publish_rev
327 );
328 }
329 return Ok(AssetSyncResult {
330 state: local,
331 kind: AssetSyncKind::LocalCacheNoRemote,
332 });
333 }
334 }
335 if let Some(repo) = find_repo_sprites_dir() {
336 let publish_rev = read_repo_publish_rev().unwrap_or(0);
337 if !opts.quiet {
338 println!(
339 "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
340 repo.display()
341 );
342 }
343 return Ok(AssetSyncResult {
344 state: LocalAssetState {
345 publish_rev,
346 sprites_dir: repo,
347 },
348 kind: AssetSyncKind::RepoDevNoRemote,
349 });
350 }
351 anyhow::bail!(
352 "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
353 Server admin: flatland-admin content publish"
354 );
355}
356
357async fn sync_from_index(
358 client: &reqwest::Client,
359 opts: AssetSyncOptions,
360 index: AssetBundleIndex,
361) -> anyhow::Result<AssetSyncResult> {
362 let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
363 if publish_rev != index.publish_rev {
364 anyhow::bail!(
365 "requested publish rev {publish_rev} but remote latest is {}",
366 index.publish_rev
367 );
368 }
369
370 if let Some(local) = read_local_state() {
371 if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
372 if verify_rev_dir(&local.sprites_dir, &index)? {
373 if !opts.quiet {
374 println!("Assets up to date (publish rev {publish_rev}).");
375 }
376 return Ok(AssetSyncResult {
377 state: local,
378 kind: AssetSyncKind::Remote,
379 });
380 }
381 }
382 }
383
384 let rev_dir = sprites_dir_for_rev(publish_rev)?;
385 std::fs::create_dir_all(&rev_dir)?;
386
387 let total = index.files.len();
388 let mut done = 0usize;
389 for (rel, entry) in &index.files {
390 let dest = rev_dir.join(rel);
391 if dest.is_file() {
392 if sha256_file(&dest)? == entry.sha256 {
393 done += 1;
394 continue;
395 }
396 }
397 if let Some(parent) = dest.parent() {
398 std::fs::create_dir_all(parent)?;
399 }
400 let url = firebase_download_url(
401 DEFAULT_FIREBASE_BUCKET,
402 &firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
403 );
404 let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
405 if sha256_bytes(&bytes) != entry.sha256 {
406 anyhow::bail!("hash mismatch for {rel} after download");
407 }
408 std::fs::write(&dest, &bytes)?;
409 done += 1;
410 if !opts.quiet {
411 println!(" [{done}/{total}] {rel}");
412 }
413 }
414
415 activate_rev_dir(&rev_dir)?;
416 let state = LocalAssetState {
417 publish_rev,
418 sprites_dir: rev_dir,
419 };
420 write_local_state(&state)?;
421 if !opts.quiet {
422 println!(
423 "Synced publish rev {publish_rev} → {}",
424 state.sprites_dir.display()
425 );
426 }
427 Ok(AssetSyncResult {
428 state,
429 kind: AssetSyncKind::Remote,
430 })
431}
432
433pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
434 if server_publish_rev == 0 {
435 return false;
436 }
437 if let Some(local) = read_local_state() {
438 if local.publish_rev >= server_publish_rev
439 && local.sprites_dir.join("manifest.yaml").is_file()
440 {
441 return false;
442 }
443 }
444 if let Some(repo_rev) = read_repo_publish_rev() {
445 if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
446 return false;
447 }
448 }
449 resolve_sprites_dir().is_none()
450}
451
452pub fn read_repo_publish_rev() -> Option<u64> {
454 let sprites = find_repo_sprites_dir()?;
455 let mut dir = sprites.as_path();
456 for _ in 0..8 {
457 let marker = dir.join(".content-publish.json");
458 if marker.is_file() {
459 #[derive(Deserialize)]
460 struct Marker {
461 rev: u64,
462 }
463 let text = std::fs::read_to_string(marker).ok()?;
464 let m: Marker = serde_json::from_str(&text).ok()?;
465 return Some(m.rev);
466 }
467 dir = dir.parent()?;
468 }
469 None
470}
471
472fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
473 for (rel, entry) in &index.files {
474 let path = dir.join(rel);
475 if !path.is_file() {
476 return Ok(false);
477 }
478 if sha256_file(&path)? != entry.sha256 {
479 return Ok(false);
480 }
481 }
482 Ok(true)
483}
484
485fn sha256_bytes(bytes: &[u8]) -> String {
486 let mut hasher = Sha256::new();
487 hasher.update(bytes);
488 hex::encode(hasher.finalize())
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::fs;
495
496 #[test]
497 fn firebase_url_encodes_slashes() {
498 let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
499 assert!(url.contains("%2F"));
500 assert!(url.contains("alt=media"));
501 }
502
503 #[test]
504 fn build_index_hashes_files() {
505 let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
506 let _ = fs::remove_dir_all(&dir);
507 fs::create_dir_all(dir.join("cells")).expect("dir");
508 fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
509 fs::write(dir.join("cells/a.png"), b"png").expect("write");
510 let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
511 assert_eq!(index.publish_rev, 3);
512 assert_eq!(index.files.len(), 2);
513 let _ = fs::remove_dir_all(&dir);
514 }
515
516 #[test]
517 fn needs_sync_false_when_repo_matches_server_rev() {
518 if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
519 let rev = read_repo_publish_rev().unwrap();
520 assert!(!needs_asset_sync(rev));
521 }
522 }
523
524 #[test]
525 fn repo_outranks_stale_synced_cache() {
526 assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
527 assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
528 assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
529 assert!(repo_sprites_outrank_cache(Some(85), None));
530 assert!(!repo_sprites_outrank_cache(None, Some(75)));
531 }
532}