use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const DEFAULT_FIREBASE_BUCKET: &str = "flatland-8911e.appspot.com";
pub const ASSETS_INDEX_OBJECT: &str = "flatland3/client-assets/latest.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AssetFileEntry {
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AssetBundleIndex {
pub publish_rev: u64,
pub published_at: String,
pub files: BTreeMap<String, AssetFileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LocalAssetState {
pub publish_rev: u64,
pub sprites_dir: PathBuf,
}
pub fn assets_root_dir() -> anyhow::Result<PathBuf> {
let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(base.join(".flatland3").join("assets"))
}
pub fn local_state_path() -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join("state.json"))
}
pub fn read_local_state() -> Option<LocalAssetState> {
let path = local_state_path().ok()?;
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn write_local_state(state: &LocalAssetState) -> anyhow::Result<()> {
let path = local_state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
Ok(())
}
pub fn sprites_dir_for_rev(publish_rev: u64) -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join(format!("rev-{publish_rev}")))
}
pub fn current_symlink_path() -> anyhow::Result<PathBuf> {
Ok(assets_root_dir()?.join("current"))
}
pub fn activate_rev_dir(rev_dir: &Path) -> anyhow::Result<()> {
let link = current_symlink_path()?;
if link.exists() {
std::fs::remove_file(&link).or_else(|_| std::fs::remove_dir_all(&link))?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(rev_dir, &link)?;
}
#[cfg(not(unix))]
{
std::fs::write(link.with_extension("path"), rev_dir)?;
std::fs::copy(
rev_dir.join("manifest.yaml"),
link.join("manifest.yaml"),
)
.ok();
}
Ok(())
}
pub fn find_repo_sprites_dir() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
for _ in 0..8 {
let candidate = dir.join("assets/gfx/sprites");
if candidate.join("manifest.yaml").is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
None
}
pub(crate) fn repo_sprites_outrank_cache(repo_rev: Option<u64>, local_rev: Option<u64>) -> bool {
match (repo_rev, local_rev) {
(Some(rr), Some(lr)) => rr >= lr,
(Some(_), None) => true,
_ => false,
}
}
pub fn resolve_sprites_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("FLATLAND_SPRITES_DIR") {
let path = PathBuf::from(p);
if path.join("manifest.yaml").is_file() {
return Some(path);
}
}
let repo = find_repo_sprites_dir();
let repo_rev = read_repo_publish_rev();
let local = read_local_state().filter(|s| s.sprites_dir.join("manifest.yaml").is_file());
let local_rev = local.as_ref().map(|s| s.publish_rev);
if repo_sprites_outrank_cache(repo_rev, local_rev) {
if let Some(repo_dir) = repo {
return Some(repo_dir);
}
}
if let Some(local) = local {
return Some(local.sprites_dir);
}
if let Some(repo_dir) = repo {
return Some(repo_dir);
}
let current = current_symlink_path().ok()?;
if current.join("manifest.yaml").is_file() {
return Some(current);
}
None
}
pub fn assets_index_url() -> String {
std::env::var("FLATLAND_ASSETS_INDEX_URL").unwrap_or_else(|_| {
firebase_download_url(DEFAULT_FIREBASE_BUCKET, ASSETS_INDEX_OBJECT)
})
}
pub fn firebase_download_url(bucket: &str, object_path: &str) -> String {
let encoded = urlencoding_encode(object_path);
format!(
"https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encoded}?alt=media"
)
}
pub fn assets_storage_prefix() -> String {
std::env::var("FLATLAND_ASSETS_PREFIX")
.unwrap_or_else(|_| "flatland3/client-assets".to_string())
}
pub fn firebase_object_path_for_prefix(prefix: &str, publish_rev: u64, relative: &str) -> String {
format!("{prefix}/rev-{publish_rev}/{relative}")
}
pub fn firebase_object_path(publish_rev: u64, relative: &str) -> String {
firebase_object_path_for_prefix(&assets_storage_prefix(), publish_rev, relative)
}
fn urlencoding_encode(path: &str) -> String {
path.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
pub fn sha256_file(path: &Path) -> anyhow::Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn build_bundle_index(sprites_dir: &Path, publish_rev: u64, published_at: &str) -> anyhow::Result<AssetBundleIndex> {
let mut files = BTreeMap::new();
walk_sprites_dir(sprites_dir, sprites_dir, &mut files)?;
Ok(AssetBundleIndex {
publish_rev,
published_at: published_at.to_string(),
files,
})
}
fn walk_sprites_dir(
root: &Path,
dir: &Path,
files: &mut BTreeMap<String, AssetFileEntry>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
if name == "previews" {
continue;
}
walk_sprites_dir(root, &path, files)?;
continue;
}
let rel = path
.strip_prefix(root)?
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with('.') || rel.contains("/.") {
continue;
}
if rel.ends_with(".html") {
continue;
}
let meta = std::fs::metadata(&path)?;
files.insert(
rel,
AssetFileEntry {
sha256: sha256_file(&path)?,
size: meta.len(),
},
);
}
Ok(())
}
pub struct AssetSyncOptions {
pub index_url: String,
pub storage_prefix: String,
pub target_rev: Option<u64>,
pub quiet: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetSyncKind {
Remote,
LocalCacheNoRemote,
RepoDevNoRemote,
}
#[derive(Debug, Clone)]
pub struct AssetSyncResult {
pub state: LocalAssetState,
pub kind: AssetSyncKind,
}
impl Default for AssetSyncOptions {
fn default() -> Self {
Self {
index_url: assets_index_url(),
storage_prefix: assets_storage_prefix(),
target_rev: None,
quiet: false,
}
}
}
pub async fn sync_assets(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
if std::env::var_os("FLATLAND_FORCE_ASSET_SYNC").is_none() {
if let Some(repo_dir) = find_repo_sprites_dir() {
if let Some(publish_rev) = read_repo_publish_rev() {
if opts.target_rev.map_or(true, |t| publish_rev >= t) {
if !opts.quiet {
println!(
"Using repo sprites at {} (rev {publish_rev}); set FLATLAND_FORCE_ASSET_SYNC=1 to pull remote.",
repo_dir.display()
);
}
return Ok(AssetSyncResult {
state: LocalAssetState {
publish_rev,
sprites_dir: repo_dir,
},
kind: AssetSyncKind::RepoDevNoRemote,
});
}
}
}
}
let client = reqwest::Client::builder()
.user_agent(format!("flatland-client-lib/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client.get(&opts.index_url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return sync_without_remote_index(opts);
}
let index: AssetBundleIndex = response.error_for_status()?.json().await?;
sync_from_index(&client, opts, index).await
}
fn sync_without_remote_index(opts: AssetSyncOptions) -> anyhow::Result<AssetSyncResult> {
if let Some(local) = read_local_state() {
if local.sprites_dir.join("manifest.yaml").is_file() {
if !opts.quiet {
println!(
"Remote latest.json not found; using cached publish rev {}.",
local.publish_rev
);
}
return Ok(AssetSyncResult {
state: local,
kind: AssetSyncKind::LocalCacheNoRemote,
});
}
}
if let Some(repo) = find_repo_sprites_dir() {
let publish_rev = read_repo_publish_rev().unwrap_or(0);
if !opts.quiet {
println!(
"Remote latest.json not found; using repo sprites at {} (rev {publish_rev}).",
repo.display()
);
}
return Ok(AssetSyncResult {
state: LocalAssetState {
publish_rev,
sprites_dir: repo,
},
kind: AssetSyncKind::RepoDevNoRemote,
});
}
anyhow::bail!(
"Gfx asset bundle not published yet (HTTP 404 on latest.json). \
Server admin: flatland-admin content publish"
);
}
async fn sync_from_index(
client: &reqwest::Client,
opts: AssetSyncOptions,
index: AssetBundleIndex,
) -> anyhow::Result<AssetSyncResult> {
let publish_rev = opts.target_rev.unwrap_or(index.publish_rev);
if publish_rev != index.publish_rev {
anyhow::bail!(
"requested publish rev {publish_rev} but remote latest is {}",
index.publish_rev
);
}
if let Some(local) = read_local_state() {
if local.publish_rev == publish_rev && local.sprites_dir.join("manifest.yaml").is_file() {
if verify_rev_dir(&local.sprites_dir, &index)? {
if !opts.quiet {
println!("Assets up to date (publish rev {publish_rev}).");
}
return Ok(AssetSyncResult {
state: local,
kind: AssetSyncKind::Remote,
});
}
}
}
let rev_dir = sprites_dir_for_rev(publish_rev)?;
std::fs::create_dir_all(&rev_dir)?;
let total = index.files.len();
let mut done = 0usize;
for (rel, entry) in &index.files {
let dest = rev_dir.join(rel);
if dest.is_file() {
if sha256_file(&dest)? == entry.sha256 {
done += 1;
continue;
}
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let url = firebase_download_url(
DEFAULT_FIREBASE_BUCKET,
&firebase_object_path_for_prefix(&opts.storage_prefix, publish_rev, rel),
);
let bytes = client.get(&url).send().await?.error_for_status()?.bytes().await?;
if sha256_bytes(&bytes) != entry.sha256 {
anyhow::bail!("hash mismatch for {rel} after download");
}
std::fs::write(&dest, &bytes)?;
done += 1;
if !opts.quiet {
println!(" [{done}/{total}] {rel}");
}
}
activate_rev_dir(&rev_dir)?;
let state = LocalAssetState {
publish_rev,
sprites_dir: rev_dir,
};
write_local_state(&state)?;
if !opts.quiet {
println!(
"Synced publish rev {publish_rev} → {}",
state.sprites_dir.display()
);
}
Ok(AssetSyncResult {
state,
kind: AssetSyncKind::Remote,
})
}
pub fn needs_asset_sync(server_publish_rev: u64) -> bool {
if server_publish_rev == 0 {
return false;
}
if let Some(local) = read_local_state() {
if local.publish_rev >= server_publish_rev
&& local.sprites_dir.join("manifest.yaml").is_file()
{
return false;
}
}
if let Some(repo_rev) = read_repo_publish_rev() {
if repo_rev >= server_publish_rev && find_repo_sprites_dir().is_some() {
return false;
}
}
resolve_sprites_dir().is_none()
}
pub fn read_repo_publish_rev() -> Option<u64> {
let sprites = find_repo_sprites_dir()?;
let mut dir = sprites.as_path();
for _ in 0..8 {
let marker = dir.join(".content-publish.json");
if marker.is_file() {
#[derive(Deserialize)]
struct Marker {
rev: u64,
}
let text = std::fs::read_to_string(marker).ok()?;
let m: Marker = serde_json::from_str(&text).ok()?;
return Some(m.rev);
}
dir = dir.parent()?;
}
None
}
fn verify_rev_dir(dir: &Path, index: &AssetBundleIndex) -> anyhow::Result<bool> {
for (rel, entry) in &index.files {
let path = dir.join(rel);
if !path.is_file() {
return Ok(false);
}
if sha256_file(&path)? != entry.sha256 {
return Ok(false);
}
}
Ok(true)
}
fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn firebase_url_encodes_slashes() {
let url = firebase_download_url("bucket", "flatland3/client-assets/latest.json");
assert!(url.contains("%2F"));
assert!(url.contains("alt=media"));
}
#[test]
fn build_index_hashes_files() {
let dir = std::env::temp_dir().join(format!("flatland-assets-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("cells")).expect("dir");
fs::write(dir.join("manifest.yaml"), "version: 1\n").expect("write");
fs::write(dir.join("cells/a.png"), b"png").expect("write");
let index = build_bundle_index(&dir, 3, "2026-01-01T00:00:00Z").expect("index");
assert_eq!(index.publish_rev, 3);
assert_eq!(index.files.len(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn needs_sync_false_when_repo_matches_server_rev() {
if read_repo_publish_rev().is_some() && find_repo_sprites_dir().is_some() {
let rev = read_repo_publish_rev().unwrap();
assert!(!needs_asset_sync(rev));
}
}
#[test]
fn repo_outranks_stale_synced_cache() {
assert!(repo_sprites_outrank_cache(Some(85), Some(75)));
assert!(repo_sprites_outrank_cache(Some(75), Some(75)));
assert!(!repo_sprites_outrank_cache(Some(70), Some(75)));
assert!(repo_sprites_outrank_cache(Some(85), None));
assert!(!repo_sprites_outrank_cache(None, Some(75)));
}
}