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

- #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:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+46 -31
View File
@@ -15,10 +15,10 @@ use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use crate::{
SyncError, SyncProvider,
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay,
settings::SyncBackend,
SyncError, SyncProvider,
};
// ---------------------------------------------------------------------------
@@ -125,10 +125,7 @@ impl SolitaireServerClient {
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
let status = resp.status();
if !status.is_success() {
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({}));
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
let msg = body["error"]
.as_str()
.or_else(|| body["message"].as_str())
@@ -166,8 +163,8 @@ impl SolitaireServerClient {
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
let old_refresh = load_refresh_token(&self.username)
.map_err(|e| SyncError::Auth(e.to_string()))?;
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
let resp = self
.client
@@ -186,9 +183,9 @@ impl SolitaireServerClient {
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let new_access = body["access_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
let new_access = body["access_token"].as_str().ok_or_else(|| {
SyncError::Serialization("missing access_token in refresh response".into())
})?;
// Server rotates refresh tokens — store the new one.
// Fall back to the old token if the field is absent (pre-rotation server).
@@ -368,13 +365,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -402,13 +405,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -480,27 +489,26 @@ impl SyncProvider for SolitaireServerClient {
impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path.
async fn share_url_from_response(
&self,
resp: reqwest::Response,
) -> Result<String, SyncError> {
async fn share_url_from_response(&self, resp: reqwest::Response) -> Result<String, SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
});
return Err(
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
},
);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let id = body["id"].as_str().ok_or_else(|| {
SyncError::Serialization("upload response missing `id`".into())
})?;
let id = body["id"]
.as_str()
.ok_or_else(|| SyncError::Serialization("upload response missing `id`".into()))?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
@@ -540,7 +548,10 @@ impl SolitaireServerClient {
/// Like [`fetch_me`] but uses an explicit token instead of reading from the
/// OS keychain. Useful immediately after login/register when the token has
/// not yet been persisted.
pub async fn fetch_me_with_token(&self, token: &str) -> Result<(String, Option<String>), SyncError> {
pub async fn fetch_me_with_token(
&self,
token: &str,
) -> Result<(String, Option<String>), SyncError> {
let url = format!("{}/api/me", self.base_url);
let resp = self
.client
@@ -552,7 +563,9 @@ impl SolitaireServerClient {
Self::extract_me_body(resp).await
}
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
async fn extract_me_body(
resp: reqwest::Response,
) -> Result<(String, Option<String>), SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(SyncError::Network(format!("GET /api/me returned {status}")));
@@ -595,7 +608,9 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
}
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<LeaderboardEntry>, SyncError> {
async fn extract_leaderboard_body(
resp: reqwest::Response,
) -> Result<Vec<LeaderboardEntry>, SyncError> {
let status = resp.status();
if status.is_success() {
resp.json()