Skip to main content

flatland_client_lib/
assets.rs

1//! Gfx sprite bundle sync for installed clients (Firebase Storage / GCS).
2
3use 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    /// Stable revision from `assets/.content-publish.json` (not process-local `content_rev`).
22    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 resolve_synced_sprites_dir() -> Option<PathBuf> {
87    if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
88        let path = PathBuf::from(p);
89        if path.join("manifest.yaml").is_file() {
90            return Some(path);
91        }
92    }
93    if let Some(state) = read_local_state() {
94        if state.sprites_dir.join("manifest.yaml").is_file() {
95            return Some(state.sprites_dir);
96        }
97    }
98    let current = current_symlink_path().ok()?;
99    if current.join("manifest.yaml").is_file() {
100        return Some(current);
101    }
102    None
103}
104
105pub fn find_repo_sprites_dir() -> Option<PathBuf> {
106    let mut dir = std::env::current_dir().ok()?;
107    for _ in 0..8 {
108        let candidate = dir.join("assets/gfx/sprites");
109        if candidate.join("manifest.yaml").is_file() {
110            return Some(candidate);
111        }
112        if !dir.pop() {
113            break;
114        }
115    }
116    None
117}
118
119pub fn resolve_sprites_dir() -> Option<PathBuf> {
120    resolve_synced_sprites_dir().or_else(find_repo_sprites_dir)
121}
122
123pub fn assets_index_url() -> String {
124    std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
125        firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
126    })
127}
128
129pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
130    let encoded = urlencoding_encode(object_path);
131    format!(
132        "https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
133    )
134}
135
136pub fn assets_storage_prefix() -> String {
137    std::env::var("FLATLAND_ASSETS_PREFIX")
138        .unwrap_or_else(|_| "flatland3/client-assets".to_string())
139}
140
141pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
142    format!("{prefix}/rev-{publish_rev}/{relative}")
143}
144
145pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
146    firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
147}
148
149fn urlencoding_encode(path: &str) -> String {
150    path.bytes()
151        .map(|b| match b {
152            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
153                (b as char).to_string()
154            }
155            _ => format!("%{b:02X}"),
156        })
157        .collect()
158}
159
160pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
161    let mut file = std::fs::File::open(path)?;
162    let mut hasher = Sha256::new();
163    let mut buf = [0u8; 8192];
164    loop {
165        let n = file.read(&mut buf)?;
166        if n == 0 {
167            break;
168        }
169        hasher.update(&buf[..n]);
170    }
171    Ok(hex::encode(hasher.finalize()))
172}
173
174/// Walk `assets/gfx/sprites` and build a publish manifest (relative paths → hash).
175pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
176    let mut files = BTreeMap::new();
177    walk_sprites_dir(sprites_dir, sprites_dir, &mut files)?;
178    Ok(AssetBundleIndex {
179        publish_rev,
180        published_at: published_at.to_string(),
181        files,
182    })
183}
184
185fn walk_sprites_dir(
186    root: &Path,
187    dir: &Path,
188    files: &mut BTreeMap<String, AssetFileEntry>,
189) -> anyhow::Result<()> {
190    for entry in std::fs::read_dir(dir)? {
191        let entry = entry?;
192        let path = entry.path();
193        if path.is_dir() {
194            let name = entry.file_name();
195            if name == "previews" {
196                continue;
197            }
198            walk_sprites_dir(root, &path, files)?;
199            continue;
200        }
201        let rel = path
202            .strip_prefix(root)?
203            .to_string_lossy()
204            .replace('\\', "/");
205        if rel.starts_with('.') || rel.contains("/.") {
206            continue;
207        }
208        if rel.ends_with(".html") {
209            continue;
210        }
211        let meta = std::fs::metadata(&path)?;
212        files.insert(
213            rel,
214            AssetFileEntry {
215                sha256: sha256_file(&path)?,
216                size: meta.len(),
217            },
218        );
219    }
220    Ok(())
221}
222
223pub struct AssetSyncOptions {
224    pub index_url: String,
225    pub storage_prefix: String,
226    pub target_rev: Option<u64>,
227    pub quiet: bool,
228}
229
230/// Outcome of [`sync_assets`] for logging / HUD toasts.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum AssetSyncKind {
233    /// Downloaded or verified files against remote `latest.json`.
234    Remote,
235    /// Remote index missing; reused `~/.flatland3` cache.
236    LocalCacheNoRemote,
237    /// Remote index missing; using repo `assets/gfx/sprites` (dev checkout).
238    RepoDevNoRemote,
239}
240
241#[derive(Debug, Clone)]
242pub struct AssetSyncResult {
243    pub state: LocalAssetState,
244    pub kind: AssetSyncKind,
245}
246
247impl Default for AssetSyncOptions {
248    fn default() -> Self {
249        Self {
250            index_url: assets_index_url(),
251            storage_prefix: assets_storage_prefix(),
252            target_rev: None,
253            quiet: false,
254        }
255    }
256}
257
258pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
259    let client = reqwest::Client::builder()
260        .user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
261        .build()?;
262    let response = client.get(&opts.index_url).send().await?;
263    if response.status() == reqwest::StatusCode::NOT_FOUND {
264        return sync_without_remote_index(opts);
265    }
266    let index: AssetBundleIndex = response.error_for_status()?.json().await?;
267    sync_from_index(&client, opts, index).await
268}
269
270fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
271    if let Some(local) = read_local_state() {
272        if local.sprites_dir.join("manifest.yaml").is_file() {
273            if !opts.quiet {
274                println!(
275                    "Remote latest.json not found; using cached publish rev {}.",
276                    local.publish_rev
277                );
278            }
279            return Ok(AssetSyncResult {
280                state: local,
281                kind: AssetSyncKind::LocalCacheNoRemote,
282            });
283        }
284    }
285    if let Some(repo) = find_repo_sprites_dir() {
286        let publish_rev = read_repo_publish_rev().unwrap_or(0);
287        if !opts.quiet {
288            println!(
289                "Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
290                repo.display()
291            );
292        }
293        return Ok(AssetSyncResult {
294            state: LocalAssetState {
295                publish_rev,
296                sprites_dir: repo,
297            },
298            kind: AssetSyncKind::RepoDevNoRemote,
299        });
300    }
301    anyhow::bail!(
302        "Gfx asset bundle not published yet (HTTP 404 on latest.json). \
303         Server admin: flatland-admin content publish"
304    );
305}
306
307async fn sync_from_index(
308    client: &reqwest::Client,
309    opts: AssetSyncOptions,
310    index: AssetBundleIndex,
311) -> anyhow::Result<AssetSyncResult> {
312    let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
313    if publish_rev != index.publish_rev {
314        anyhow::bail!(
315            "requested publish rev {publish_rev} but remote latest is {}",
316            index.publish_rev
317        );
318    }
319
320    if let Some(local) = read_local_state() {
321        if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
322            if verify_rev_dir(&local.sprites_dir, &index)? {
323                if !opts.quiet {
324                    println!("Assets up to date (publish rev {publish_rev}).");
325                }
326                return Ok(AssetSyncResult {
327                    state: local,
328                    kind: AssetSyncKind::Remote,
329                });
330            }
331        }
332    }
333
334    let rev_dir = sprites_dir_for_rev(publish_rev)?;
335    std::fs::create_dir_all(&rev_dir)?;
336
337    let total = index.files.len();
338    let mut done = 0usize;
339    for (rel, entry) in &index.files {
340        let dest = rev_dir.join(rel);
341        if dest.is_file() {
342            if sha256_file(&dest)? == entry.sha256 {
343                done += 1;
344                continue;
345            }
346        }
347        if let Some(parent) = dest.parent() {
348            std::fs::create_dir_all(parent)?;
349        }
350        let url = firebase_download_url(
351            DEFAULT_FIREBASE_BUCKET,
352            &firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
353        );
354        let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
355        if sha256_bytes(&bytes) != entry.sha256 {
356            anyhow::bail!("hash mismatch for {rel} after download");
357        }
358        std::fs::write(&dest, &bytes)?;
359        done += 1;
360        if !opts.quiet {
361            println!("  [{done}/{total}] {rel}");
362        }
363    }
364
365    activate_rev_dir(&rev_dir)?;
366    let state = LocalAssetState {
367        publish_rev,
368        sprites_dir: rev_dir,
369    };
370    write_local_state(&state)?;
371    if !opts.quiet {
372        println!(
373            "Synced publish rev {publish_rev} → {}",
374            state.sprites_dir.display()
375        );
376    }
377    Ok(AssetSyncResult {
378        state,
379        kind: AssetSyncKind::Remote,
380    })
381}
382
383pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
384    if server_publish_rev == 0 {
385        return false;
386    }
387    if let Some(local) = read_local_state() {
388        if local.publish_rev >= server_publish_rev
389            && local.sprites_dir.join("manifest.yaml").is_file()
390        {
391            return false;
392        }
393    }
394    if let Some(repo_rev) = read_repo_publish_rev() {
395        if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
396            return false;
397        }
398    }
399    resolve_sprites_dir().is_none()
400}
401
402/// Publish rev from `assets/.content-publish.json` when running from a dev checkout.
403pub fn read_repo_publish_rev() -> Option<u64> {
404    let sprites = find_repo_sprites_dir()?;
405    let mut dir = sprites.as_path();
406    for _ in 0..8 {
407        let marker = dir.join(".content-publish.json");
408        if marker.is_file() {
409            #[derive(Deserialize)]
410            struct Marker {
411                rev: u64,
412            }
413            let text = std::fs::read_to_string(marker).ok()?;
414            let m: Marker = serde_json::from_str(&text).ok()?;
415            return Some(m.rev);
416        }
417        dir = dir.parent()?;
418    }
419    None
420}
421
422fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
423    for (rel, entry) in &index.files {
424        let path = dir.join(rel);
425        if !path.is_file() {
426            return Ok(false);
427        }
428        if sha256_file(&path)? != entry.sha256 {
429            return Ok(false);
430        }
431    }
432    Ok(true)
433}
434
435fn sha256_bytes(bytes: &[u8]) -> String {
436    let mut hasher = Sha256::new();
437    hasher.update(bytes);
438    hex::encode(hasher.finalize())
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::fs;
445
446    #[test]
447    fn firebase_url_encodes_slashes() {
448        let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
449        assert!(url.contains("%2F"));
450        assert!(url.contains("alt=media"));
451    }
452
453    #[test]
454    fn build_index_hashes_files() {
455        let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
456        let _ = fs::remove_dir_all(&dir);
457        fs::create_dir_all(dir.join("cells")).expect("dir");
458        fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
459        fs::write(dir.join("cells/a.png"), b"png").expect("write");
460        let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
461        assert_eq!(index.publish_rev, 3);
462        assert_eq!(index.files.len(), 2);
463        let _ = fs::remove_dir_all(&dir);
464    }
465
466    #[test]
467    fn needs_sync_false_when_repo_matches_server_rev() {
468        if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
469            let rev = read_repo_publish_rev().unwrap();
470            assert!(!needs_asset_sync(rev));
471        }
472    }
473}