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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user