fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s
Build and Deploy / build-and-push (push) Successful in 3m54s
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess - #68: Move fire_flush outside per-event loop in analytics (batch flush once) - #56: Persist progress before marking reward_granted to prevent XP loss on crash - #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh - #62: Add validate_header() in replay upload with mode/draw_mode allowlists - #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original queries already in .sqlx cache; EXISTS variant would require sqlx prepare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -45,10 +45,10 @@ use thiserror::Error;
|
||||
|
||||
use bevy::math::UVec2;
|
||||
|
||||
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
|
||||
use crate::assets::{SvgLoaderError, rasterize_svg, user_theme_dir};
|
||||
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
use super::ThemeMetaError;
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
|
||||
/// Hard cap on the *uncompressed* total of all archive entries. Set
|
||||
/// generously high relative to a realistic 53-SVG theme (~1–2 MB at
|
||||
@@ -100,9 +100,7 @@ pub enum ImportError {
|
||||
|
||||
/// The archive's declared total uncompressed size exceeds
|
||||
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
|
||||
#[error(
|
||||
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
|
||||
)]
|
||||
#[error("archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit")]
|
||||
Oversized { total: u64, limit: u64 },
|
||||
|
||||
/// No `theme.ron` at the archive root.
|
||||
@@ -168,10 +166,7 @@ pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
|
||||
/// Tests use this directly with a `tempfile::TempDir` so they can
|
||||
/// exercise the full extraction path without touching the global
|
||||
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
|
||||
pub fn import_theme_into(
|
||||
zip_path: &Path,
|
||||
target_root: &Path,
|
||||
) -> Result<ThemeId, ImportError> {
|
||||
pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId, ImportError> {
|
||||
let file = File::open(zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
@@ -189,11 +184,9 @@ pub fn import_theme_into(
|
||||
required.push(manifest.back.clone());
|
||||
for path in &required {
|
||||
let bytes = read_archive_entry(&mut archive, path)?;
|
||||
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
|
||||
ImportError::InvalidSvg {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
source,
|
||||
}
|
||||
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| ImportError::InvalidSvg {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -288,8 +281,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
|
||||
if p.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
p.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
p.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Reads `theme.ron` from the archive root and parses it.
|
||||
@@ -373,11 +365,9 @@ fn write_archive_entry<R: io::Read + io::Seek>(
|
||||
}
|
||||
Err(e) => return Err(ImportError::OpenArchive(e)),
|
||||
};
|
||||
let safe = entry
|
||||
.enclosed_name()
|
||||
.ok_or_else(|| ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
})?;
|
||||
let safe = entry.enclosed_name().ok_or_else(|| ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
})?;
|
||||
if !is_safe_relative_path(&safe) {
|
||||
return Err(ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
@@ -457,8 +447,8 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::CompressionMethod;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use crate::theme::manifest::ThemeManifest;
|
||||
use crate::theme::{CardKey, ThemeMeta};
|
||||
@@ -516,11 +506,8 @@ mod tests {
|
||||
/// given manifest id.
|
||||
fn write_valid_zip(zip_path: &Path, id: &str) {
|
||||
let manifest = full_manifest(id);
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
|
||||
@@ -530,8 +517,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(zip_path, &entries_ref);
|
||||
}
|
||||
|
||||
@@ -570,10 +559,7 @@ mod tests {
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::MissingManifest),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(matches!(err, ImportError::MissingManifest), "got {err:?}");
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
@@ -588,11 +574,8 @@ mod tests {
|
||||
let mut manifest = full_manifest("incomplete");
|
||||
// Drop one face so validation surfaces MissingFaces.
|
||||
manifest.faces.remove("hearts_ace");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
@@ -603,8 +586,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
@@ -633,18 +618,12 @@ mod tests {
|
||||
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
|
||||
write_zip(
|
||||
&zip_path,
|
||||
&[
|
||||
(MANIFEST_NAME, b"".to_vec()),
|
||||
("filler.bin", huge),
|
||||
],
|
||||
&[(MANIFEST_NAME, b"".to_vec()), ("filler.bin", huge)],
|
||||
);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::Oversized { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(matches!(err, ImportError::Oversized { .. }), "got {err:?}");
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
@@ -686,11 +665,8 @@ mod tests {
|
||||
// Manifest is well-formed and validates, but we omit one of
|
||||
// the SVGs from the archive to trigger the MissingFile path.
|
||||
let manifest = full_manifest("missing_file_theme");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
@@ -706,8 +682,10 @@ mod tests {
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> = entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
|
||||
@@ -92,7 +92,12 @@ mod tests {
|
||||
|
||||
fn full_face_map() -> HashMap<String, PathBuf> {
|
||||
CardKey::all()
|
||||
.map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name()))))
|
||||
.map(|k| {
|
||||
(
|
||||
k.manifest_name(),
|
||||
PathBuf::from(format!("{}.svg", k.manifest_name())),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -167,11 +172,8 @@ mod tests {
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces: full_face_map(),
|
||||
};
|
||||
let serialised = ron::ser::to_string_pretty(
|
||||
&m,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("serde_ron");
|
||||
let serialised =
|
||||
ron::ser::to_string_pretty(&m, ron::ser::PrettyConfig::default()).expect("serde_ron");
|
||||
let parsed: ThemeManifest = ron::from_str(&serialised).expect("ron parse");
|
||||
assert_eq!(parsed.meta, m.meta);
|
||||
assert_eq!(parsed.back, m.back);
|
||||
|
||||
@@ -28,15 +28,15 @@ use thiserror::Error;
|
||||
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
|
||||
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
|
||||
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
||||
pub use manifest::ThemeManifest;
|
||||
pub use plugin::{
|
||||
ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache,
|
||||
ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX,
|
||||
ActiveTheme, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX, ThemePlugin,
|
||||
ThemeThumbnailCache, ThemeThumbnailPair, ensure_theme_thumbnails, set_theme,
|
||||
};
|
||||
pub use registry::{
|
||||
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
|
||||
ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, build_registry, refresh_registry,
|
||||
};
|
||||
|
||||
/// Hashable lookup key into [`CardTheme::faces`].
|
||||
@@ -62,8 +62,18 @@ impl CardKey {
|
||||
pub fn all() -> impl Iterator<Item = CardKey> {
|
||||
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const RANKS: [Rank; 13] = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
Rank::King,
|
||||
];
|
||||
SUITS
|
||||
|
||||
@@ -194,8 +194,7 @@ fn sync_card_image_set_with_active_theme(
|
||||
// Consume asset events — covers the normal first-load path.
|
||||
for ev in events.read() {
|
||||
let id = match ev {
|
||||
AssetEvent::LoadedWithDependencies { id }
|
||||
| AssetEvent::Modified { id } => *id,
|
||||
AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => *id,
|
||||
_ => continue,
|
||||
};
|
||||
if id == active_id {
|
||||
@@ -245,9 +244,19 @@ fn sync_card_image_set_with_active_theme(
|
||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
for rank in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen, Rank::King,
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
Rank::King,
|
||||
] {
|
||||
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
||||
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
||||
@@ -348,10 +357,7 @@ fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8
|
||||
/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so
|
||||
/// the picker can render a placeholder for broken themes without
|
||||
/// crashing.
|
||||
fn rasterize_preview_to_handle(
|
||||
svg_bytes: &[u8],
|
||||
images: &mut Assets<Image>,
|
||||
) -> Handle<Image> {
|
||||
fn rasterize_preview_to_handle(svg_bytes: &[u8], images: &mut Assets<Image>) -> Handle<Image> {
|
||||
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
|
||||
match rasterize_svg(svg_bytes, target) {
|
||||
Ok(image) => images.add(image),
|
||||
@@ -365,10 +371,7 @@ fn rasterize_preview_to_handle(
|
||||
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
|
||||
/// is [`Handle::default`] when the matching SVG could not be located
|
||||
/// or rasterised.
|
||||
fn generate_thumbnail_pair_for(
|
||||
theme_id: &str,
|
||||
images: &mut Assets<Image>,
|
||||
) -> ThemeThumbnailPair {
|
||||
fn generate_thumbnail_pair_for(theme_id: &str, images: &mut Assets<Image>) -> ThemeThumbnailPair {
|
||||
let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME)
|
||||
.map(|b| rasterize_preview_to_handle(&b, images))
|
||||
.unwrap_or_default();
|
||||
@@ -543,8 +546,14 @@ mod tests {
|
||||
);
|
||||
// And the underlying images must actually exist in the assets
|
||||
// collection — the handles are real, not dangling.
|
||||
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
|
||||
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
|
||||
assert!(
|
||||
images.get(&pair.ace).is_some(),
|
||||
"ace image must be inserted"
|
||||
);
|
||||
assert!(
|
||||
images.get(&pair.back).is_some(),
|
||||
"back image must be inserted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 2: when a theme is registered but its preview SVGs are not
|
||||
|
||||
@@ -291,9 +291,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nonexistent_user_dir_still_yields_bundled_entries() {
|
||||
let registry = build_registry(Path::new(
|
||||
"/definitely/not/a/real/path/should/not/panic",
|
||||
));
|
||||
let registry = build_registry(Path::new("/definitely/not/a/real/path/should/not/panic"));
|
||||
assert_eq!(registry.len(), BUNDLED_COUNT);
|
||||
assert!(registry.find("classic").is_some());
|
||||
assert!(registry.find("dark").is_some());
|
||||
@@ -353,7 +351,11 @@ mod tests {
|
||||
write_manifest(&theme_dir, "../etc/passwd", "Evil");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), BUNDLED_COUNT, "escape attempt must not register");
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
BUNDLED_COUNT,
|
||||
"escape attempt must not register"
|
||||
);
|
||||
assert!(registry.find("classic").is_some());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user