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 let marker = link.with_extension("path");
78 std::fs::write(&marker, rev_dir.to_string_lossy().as_bytes())?;
79 let _ = std::fs::create_dir_all(&link);
80 let _ = std::fs::copy(rev_dir.join("manifest.yaml"), link.join("manifest.yaml"));
81 }
82 Ok(())
83}
84
85pub fn find_repo_sprites_dir() -> Option<PathBuf> {
86 let mut dir = std::env::current_dir().ok()?;
87 for _ in 0..8 {
88 let candidate = dir.join("assets/gfx/sprites");
89 if candidate.join("manifest.yaml").is_file() {
90 return Some(candidate);
91 }
92 if !dir.pop() {
93 break;
94 }
95 }
96 None
97}
98
99pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
101 match (repo_rev, local_rev) {
102 (Some(rr), Some(lr)) => rr >= lr,
103 (Some(_), None) => true,
104 _ => false,
105 }
106}
107
108pub fn resolve_sprites_dir() -> Option<PathBuf> {
115 if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
116 let path = PathBuf::from(p);
117 if path.join("manifest.yaml").is_file() {
118 return Some(path);
119 }
120 }
121
122 let repo = find_repo_sprites_dir();
123 let repo_rev = read_repo_publish_rev();
124 let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
125 let local_rev = local.as_ref().map(|s| s.publish_rev);
126
127 if repo_sprites_outrank_cache(repo_rev, local_rev) {
128 if let Some(repo_dir) = repo {
129 return Some(repo_dir);
130 }
131 }
132 if let Some(local) = local {
133 return Some(local.sprites_dir);
134 }
135 if let Some(repo_dir) = repo {
136 return Some(repo_dir);
137 }
138
139 let current = current_symlink_path().ok()?;
140 if current.join("manifest.yaml").is_file() {
141 return Some(current);
142 }
143 None
144}
145
146pub fn assets_index_url() -> String {
147 std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
148 firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
149 })
150}
151
152pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
153 let encoded = urlencoding_encode(object_path);
154 format!(
155 "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
156 )
157}
158
159pub fn assets_storage_prefix() -> String {
160 std::env::var("FLATLAND_ASSETS_PREFIX")
161 .unwrap_or_else(|_| "flatland3/client-assets".to_string())
162}
163
164pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
165 format!("{prefix}/rev-{publish_rev}/{relative}")
166}
167
168pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
169 firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
170}
171
172fn urlencoding_encode(path: &str) -> String {
173 path.bytes()
174 .map(|b| match b {
175 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
176 (b as char).to_string()
177 }
178 _ => format!("%{b:02X}"),
179 })
180 .collect()
181}
182
183pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
184 let mut file = std::fs::File::open(path)?;
185 let mut hasher = Sha256::new();
186 let mut buf = [0u8; 8192];
187 loop {
188 let n = file.read(&mut buf)?;
189 if n == 0 {
190 break;
191 }
192 hasher.update(&buf[..n]);
193 }
194 Ok(hex::encode(hasher.finalize()))
195}
196
197pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
199 build_client_bundle_index(sprites_dir, None, publish_rev, published_at)
200}
201
202pub fn build_client_bundle_index(
205 sprites_dir: &Path,
206 paperdoll_dir: Option<&Path>,
207 publish_rev: u64,
208 published_at: &str,
209) -> anyhow::Result<AssetBundleIndex> {
210 let mut files = BTreeMap::new();
211 walk_asset_dir(sprites_dir, sprites_dir, "", &mut files)?;
212 if let Some(paperdoll) = paperdoll_dir {
213 if paperdoll.is_dir() {
214 walk_asset_dir(paperdoll, paperdoll, "paperdoll/", &mut files)?;
215 }
216 }
217 Ok(AssetBundleIndex {
218 publish_rev,
219 published_at: published_at.to_string(),
220 files,
221 })
222}
223
224pub fn resolve_bundle_file_path(
226 sprites_dir: &Path,
227 paperdoll_dir: Option<&Path>,
228 rel: &str,
229) -> PathBuf {
230 if let Some(rest) = rel.strip_prefix("paperdoll/") {
231 if let Some(root) = paperdoll_dir {
232 return root.join(rest);
233 }
234 }
235 sprites_dir.join(rel)
236}
237
238pub fn resolve_paperdoll_dir() -> Option<PathBuf> {
240 if let Ok(p) = std::env::var("FLATLAND_PAPERDOLL_DIR") {
241 let path = PathBuf::from(p);
242 if path.is_dir() {
243 return Some(path);
244 }
245 }
246 if let Ok(assets) = std::env::var("FLATLAND_ASSETS") {
247 let path = PathBuf::from(assets).join("paperdoll");
248 if path.is_dir() {
249 return Some(path);
250 }
251 }
252 if let Some(sprites) = resolve_sprites_dir() {
253 let path = sprites.join("paperdoll");
254 if path.is_dir() {
255 return Some(path);
256 }
257 }
258 let mut dir = std::env::current_dir().ok()?;
259 for _ in 0..8 {
260 let candidate = dir.join("assets/paperdoll");
261 if candidate.is_dir() {
262 return Some(candidate);
263 }
264 if !dir.pop() {
265 break;
266 }
267 }
268 None
269}
270
271fn walk_asset_dir(
272 root: &Path,
273 dir: &Path,
274 prefix: &str,
275 files: &mut BTreeMap<String, AssetFileEntry>,
276) -> anyhow::Result<()> {
277 for entry in std::fs::read_dir(dir)? {
278 let entry = entry?;
279 let path = entry.path();
280 if path.is_dir() {
281 let name = entry.file_name();
282 if name == "previews" {
283 continue;
284 }
285 walk_asset_dir(root, &path, prefix, files)?;
286 continue;
287 }
288 let rel = path
289 .strip_prefix(root)?
290 .to_string_lossy()
291 .replace('\\', "/");
292 if rel.starts_with('.') || rel.contains("/.") {
293 continue;
294 }
295 if rel.ends_with(".html") {
296 continue;
297 }
298 let meta = std::fs::metadata(&path)?;
299 files.insert(
300 format!("{prefix}{rel}"),
301 AssetFileEntry {
302 sha256: sha256_file(&path)?,
303 size: meta.len(),
304 },
305 );
306 }
307 Ok(())
308}
309
310pub struct AssetSyncOptions {
311 pub index_url: String,
312 pub storage_prefix: String,
313 pub target_rev: Option<u64>,
314 pub quiet: bool,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum AssetSyncKind {
320 Remote,
322 LocalCacheNoRemote,
324 RepoDevNoRemote,
326}
327
328#[derive(Debug, Clone)]
329pub struct AssetSyncResult {
330 pub state: LocalAssetState,
331 pub kind: AssetSyncKind,
332}
333
334impl Default for AssetSyncOptions {
335 fn default() -> Self {
336 Self {
337 index_url: assets_index_url(),
338 storage_prefix: assets_storage_prefix(),
339 target_rev: None,
340 quiet: false,
341 }
342 }
343}
344
345pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
346 if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_none() {
349 if let Some(repo_dir) = find_repo_sprites_dir() {
350 if let Some(publish_rev) = read_repo_publish_rev() {
351 if opts.target_rev.map_or(true, |t| publish_rev >= t) {
352 if !opts.quiet {
353 println!(
354 "Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
355 repo_dir.display()
356 );
357 }
358 return Ok(AssetSyncResult {
359 state: LocalAssetState {
360 publish_rev,
361 sprites_dir: repo_dir,
362 },
363 kind: AssetSyncKind::RepoDevNoRemote,
364 });
365 }
366 }
367 }
368 }
369
370 let client = reqwest::Client::builder()
371 .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
372 .connect_timeout(std::time::Duration::from_secs(5))
373 .timeout(std::time::Duration::from_secs(30))
374 .build()?;
375 let response = client.get(&opts.index_url).send().await?;
376 if response.status() == reqwest::StatusCode::NOT_FOUND {
377 return sync_without_remote_index(opts);
378 }
379 let index: AssetBundleIndex = response.error_for_status()?.json().await?;
380 sync_from_index(&client, opts, index).await
381}
382
383fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
384 if let Some(local) = read_local_state() {
385 if local.sprites_dir.join("manifest.yaml").is_file() {
386 if !opts.quiet {
387 println!(
388 "Remote latest.json not found; using cached publish rev {}.",
389 local.publish_rev
390 );
391 }
392 return Ok(AssetSyncResult {
393 state: local,
394 kind: AssetSyncKind::LocalCacheNoRemote,
395 });
396 }
397 }
398 if let Some(repo) = find_repo_sprites_dir() {
399 let publish_rev = read_repo_publish_rev().unwrap_or(0);
400 if !opts.quiet {
401 println!(
402 "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
403 repo.display()
404 );
405 }
406 return Ok(AssetSyncResult {
407 state: LocalAssetState {
408 publish_rev,
409 sprites_dir: repo,
410 },
411 kind: AssetSyncKind::RepoDevNoRemote,
412 });
413 }
414 anyhow::bail!(
415 "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
416 Server admin: flatland-admin content publish"
417 );
418}
419
420async fn sync_from_index(
421 client: &reqwest::Client,
422 opts: AssetSyncOptions,
423 index: AssetBundleIndex,
424) -> anyhow::Result<AssetSyncResult> {
425 let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
426 if publish_rev != index.publish_rev {
427 anyhow::bail!(
428 "requested publish rev {publish_rev} but remote latest is {}",
429 index.publish_rev
430 );
431 }
432
433 if let Some(local) = read_local_state() {
434 if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
435 if verify_rev_dir(&local.sprites_dir, &index)? {
436 if !opts.quiet {
437 println!("Assets up to date (publish rev {publish_rev}).");
438 }
439 return Ok(AssetSyncResult {
440 state: local,
441 kind: AssetSyncKind::Remote,
442 });
443 }
444 }
445 }
446
447 let rev_dir = sprites_dir_for_rev(publish_rev)?;
448 std::fs::create_dir_all(&rev_dir)?;
449
450 let total = index.files.len();
451 let mut done = 0usize;
452 for (rel, entry) in &index.files {
453 let dest = rev_dir.join(rel);
454 if dest.is_file() {
455 if sha256_file(&dest)? == entry.sha256 {
456 done += 1;
457 continue;
458 }
459 }
460 if let Some(parent) = dest.parent() {
461 std::fs::create_dir_all(parent)?;
462 }
463 let url = firebase_download_url(
464 DEFAULT_FIREBASE_BUCKET,
465 &firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
466 );
467 let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
468 if sha256_bytes(&bytes) != entry.sha256 {
469 anyhow::bail!("hash mismatch for {rel} after download");
470 }
471 std::fs::write(&dest, &bytes)?;
472 done += 1;
473 if !opts.quiet {
474 println!(" [{done}/{total}] {rel}");
475 }
476 }
477
478 activate_rev_dir(&rev_dir)?;
479 let state = LocalAssetState {
480 publish_rev,
481 sprites_dir: rev_dir,
482 };
483 write_local_state(&state)?;
484 if !opts.quiet {
485 println!(
486 "Synced publish rev {publish_rev} → {}",
487 state.sprites_dir.display()
488 );
489 }
490 Ok(AssetSyncResult {
491 state,
492 kind: AssetSyncKind::Remote,
493 })
494}
495
496pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
497 if server_publish_rev == 0 {
498 return false;
499 }
500 if let Some(local) = read_local_state() {
501 if local.publish_rev >= server_publish_rev
502 && local.sprites_dir.join("manifest.yaml").is_file()
503 {
504 return false;
505 }
506 }
507 if let Some(repo_rev) = read_repo_publish_rev() {
508 if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
509 return false;
510 }
511 }
512 resolve_sprites_dir().is_none()
513}
514
515pub fn read_repo_publish_rev() -> Option<u64> {
517 let sprites = find_repo_sprites_dir()?;
518 let mut dir = sprites.as_path();
519 for _ in 0..8 {
520 let marker = dir.join(".content-publish.json");
521 if marker.is_file() {
522 #[derive(Deserialize)]
523 struct Marker {
524 rev: u64,
525 }
526 let text = std::fs::read_to_string(marker).ok()?;
527 let m: Marker = serde_json::from_str(&text).ok()?;
528 return Some(m.rev);
529 }
530 dir = dir.parent()?;
531 }
532 None
533}
534
535fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
536 for (rel, entry) in &index.files {
537 let path = dir.join(rel);
538 if !path.is_file() {
539 return Ok(false);
540 }
541 if sha256_file(&path)? != entry.sha256 {
542 return Ok(false);
543 }
544 }
545 Ok(true)
546}
547
548fn sha256_bytes(bytes: &[u8]) -> String {
549 let mut hasher = Sha256::new();
550 hasher.update(bytes);
551 hex::encode(hasher.finalize())
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use std::fs;
558
559 #[test]
560 fn firebase_url_encodes_slashes() {
561 let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
562 assert!(url.contains("%2F"));
563 assert!(url.contains("alt=media"));
564 }
565
566 #[test]
567 fn build_index_hashes_files() {
568 let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
569 let _ = fs::remove_dir_all(&dir);
570 fs::create_dir_all(dir.join("cells")).expect("dir");
571 fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
572 fs::write(dir.join("cells/a.png"), b"png").expect("write");
573 let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
574 assert_eq!(index.publish_rev, 3);
575 assert_eq!(index.files.len(), 2);
576 let _ = fs::remove_dir_all(&dir);
577 }
578
579 #[test]
580 fn needs_sync_false_when_repo_matches_server_rev() {
581 if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
582 let rev = read_repo_publish_rev().unwrap();
583 assert!(!needs_asset_sync(rev));
584 }
585 }
586
587 #[test]
588 fn repo_outranks_stale_synced_cache() {
589 assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
590 assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
591 assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
592 assert!(repo_sprites_outrank_cache(Some(85), None));
593 assert!(!repo_sprites_outrank_cache(None, Some(75)));
594 }
595}