feat(engine): "Copy share link" Stats button — clipboards the replay URL

Quat: replay sharing as the next punch-list item.

End-to-end:

1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
   `AsyncComputeTaskPool` and stores the handle in the new
   `PendingReplayUpload` resource. The previous in-flight task (if
   any) is dropped — the most recent win is the one whose share link
   the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
   each frame; on success writes `<server>/replays/<id>` to
   `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
   is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
   Click writes `LastSharedReplayUrl` to the OS clipboard via
   `arboard` and surfaces a "Copied: <url>" toast.

Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.

New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
  `solitaire_engine`. `default-features = false` keeps the X11/Wayland
  binary-feature deps off the dependency graph; arboard handles the
  fallback. Approved per the ASK BEFORE rule.

Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 05:32:57 +00:00
parent bdac754b26
commit 540869c851
7 changed files with 241 additions and 40 deletions
+8 -8
View File
@@ -56,13 +56,13 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// Upload a winning replay to the backend so it's available for web
/// playback at `<server>/replays/<id>`. Default returns
/// `UnsupportedPlatform` so backends without a server (e.g.
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
/// push-on-win system, matching the same pattern `pull` / `push`
/// follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
/// Upload a winning replay to the backend. On success, returns the
/// shareable web URL the player can copy to their clipboard
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
/// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
@@ -101,7 +101,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await
}
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await
}
}
+33 -18
View File
@@ -358,13 +358,12 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await
}
/// Upload a winning replay to `POST /api/replays`. Mirrors the
/// `push` auth flow: 401 triggers a token refresh and one retry.
/// Non-success statuses are surfaced as the relevant `SyncError`
/// variant so the engine's push-on-win system can downgrade
/// network/auth failures into a quiet log without aborting the
/// game flow.
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
/// Upload a winning replay to `POST /api/replays`. On success the
/// server returns `{ "id": "<uuid>" }`; this method composes that
/// id with the configured base URL into the player-shareable
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
/// auth flow: 401 triggers a token refresh and one retry.
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/replays", self.base_url);
@@ -388,22 +387,38 @@ impl SyncProvider for SolitaireServerClient {
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return check_replay_status(resp.status());
return self.share_url_from_response(resp).await;
}
check_replay_status(resp.status())
self.share_url_from_response(resp).await
}
}
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
if status.is_success() {
Ok(())
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}")))
} else {
Err(SyncError::Network(format!("server returned {status}")))
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> {
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}"))
});
}
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())
})?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
}