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
Generated
+72 -3
View File
@@ -44,7 +44,7 @@ dependencies = [
"accesskit_consumer", "accesskit_consumer",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -313,6 +313,23 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"x11rb",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@@ -1972,6 +1989,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -2882,6 +2908,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]] [[package]]
name = "etcetera" name = "etcetera"
version = "0.8.0" version = "0.8.0"
@@ -4968,6 +5000,18 @@ dependencies = [
"objc2-quartz-core", "objc2-quartz-core",
] ]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-audio-toolbox" name = "objc2-audio-toolbox"
version = "0.3.2" version = "0.3.2"
@@ -5065,6 +5109,19 @@ dependencies = [
"objc2 0.6.4", "objc2 0.6.4",
] ]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-core-image" name = "objc2-core-image"
version = "0.2.2" version = "0.2.2"
@@ -5121,6 +5178,17 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-link-presentation" name = "objc2-link-presentation"
version = "0.2.2" version = "0.2.2"
@@ -5129,7 +5197,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
@@ -6856,6 +6924,7 @@ dependencies = [
name = "solitaire_engine" name = "solitaire_engine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard",
"async-trait", "async-trait",
"bevy", "bevy",
"chrono", "chrono",
@@ -9516,7 +9585,7 @@ dependencies = [
"libc", "libc",
"ndk", "ndk",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-ui-kit", "objc2-ui-kit",
"orbclient", "orbclient",
+1
View File
@@ -30,6 +30,7 @@ dirs = "6"
keyring = "4" keyring = "4"
keyring-core = "1" keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false }
solitaire_core = { path = "solitaire_core" } solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" } solitaire_sync = { path = "solitaire_sync" }
+8 -8
View File
@@ -56,13 +56,13 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> { async fn delete_account(&self) -> Result<(), SyncError> {
Ok(()) Ok(())
} }
/// Upload a winning replay to the backend so it's available for web /// Upload a winning replay to the backend. On success, returns the
/// playback at `<server>/replays/<id>`. Default returns /// shareable web URL the player can copy to their clipboard
/// `UnsupportedPlatform` so backends without a server (e.g. /// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
/// `LocalOnlyProvider`) are silently no-op'd by the engine's /// so backends without a server (e.g. `LocalOnlyProvider`) are
/// push-on-win system, matching the same pattern `pull` / `push` /// silently no-op'd by the engine's push-on-win system, matching
/// follow. /// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> { async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform) Err(SyncError::UnsupportedPlatform)
} }
} }
@@ -101,7 +101,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> { async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await (**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 (**self).push_replay(replay).await
} }
} }
+33 -18
View File
@@ -358,13 +358,12 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await extract_leaderboard_body(resp).await
} }
/// Upload a winning replay to `POST /api/replays`. Mirrors the /// Upload a winning replay to `POST /api/replays`. On success the
/// `push` auth flow: 401 triggers a token refresh and one retry. /// server returns `{ "id": "<uuid>" }`; this method composes that
/// Non-success statuses are surfaced as the relevant `SyncError` /// id with the configured base URL into the player-shareable
/// variant so the engine's push-on-win system can downgrade /// `<base>/replays/<id>` link and returns it. Mirrors the `push`
/// network/auth failures into a quiet log without aborting the /// auth flow: 401 triggers a token refresh and one retry.
/// game flow. async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
let token = self.access_token()?; let token = self.access_token()?;
let url = format!("{}/api/replays", self.base_url); let url = format!("{}/api/replays", self.base_url);
@@ -388,22 +387,38 @@ impl SyncProvider for SolitaireServerClient {
.send() .send()
.await .await
.map_err(|e| SyncError::Network(e.to_string()))?; .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> { impl SolitaireServerClient {
if status.is_success() { /// Pulled out of `push_replay` so both the first attempt and the
Ok(()) /// post-401-retry attempt go through the same parse path.
} else if status == reqwest::StatusCode::UNAUTHORIZED async fn share_url_from_response(
|| status == reqwest::StatusCode::FORBIDDEN &self,
{ resp: reqwest::Response,
Err(SyncError::Auth(format!("server returned {status}"))) ) -> Result<String, SyncError> {
} else { let status = resp.status();
Err(SyncError::Network(format!("server returned {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))
} }
} }
+1
View File
@@ -21,6 +21,7 @@ tiny-skia = { workspace = true }
ron = { workspace = true } ron = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
arboard = { workspace = true }
[dev-dependencies] [dev-dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
+77
View File
@@ -74,6 +74,26 @@ pub struct StatsCell;
#[derive(Resource, Debug, Default, Clone)] #[derive(Resource, Debug, Default, Clone)]
pub struct ReplayHistoryResource(pub ReplayHistory); pub struct ReplayHistoryResource(pub ReplayHistory);
/// Most recent shareable replay URL written by `sync_plugin` after the
/// `SyncProvider::push_replay` task completes successfully. `None`
/// until the player wins a game on a server-backed sync backend;
/// repopulated on each subsequent win.
///
/// The Stats overlay's "Copy share link" button reads from here and
/// writes the URL to the OS clipboard via `arboard`. Not persisted to
/// disk — the URL is recoverable by re-uploading the same replay
/// (still in `replays.json`), so the session-bound lifetime is fine
/// for a v1 share affordance.
#[derive(Resource, Debug, Default, Clone)]
pub struct LastSharedReplayUrl(pub Option<String>);
/// Marker on the "Copy share link" button inside the Stats modal.
/// Click writes [`LastSharedReplayUrl`] to the OS clipboard via
/// `arboard` and surfaces a confirmation toast. Hidden / disabled
/// when no shareable URL is available.
#[derive(Component, Debug)]
pub struct CopyShareLinkButton;
/// Currently-selected index into [`ReplayHistoryResource::0`].`replays`. /// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
/// ///
/// `0` is the most recent win and is the default on every modal open. /// `0` is the most recent win and is the default on every modal open.
@@ -175,6 +195,7 @@ impl Plugin for StatsPlugin {
.insert_resource(ReplayHistoryResource(initial_history)) .insert_resource(ReplayHistoryResource(initial_history))
.init_resource::<SelectedReplayIndex>() .init_resource::<SelectedReplayIndex>()
.insert_resource(LatestReplayPath(replay_path)) .insert_resource(LatestReplayPath(replay_path))
.init_resource::<LastSharedReplayUrl>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
@@ -210,6 +231,7 @@ impl Plugin for StatsPlugin {
refresh_replay_history_on_win.after(GameMutation), refresh_replay_history_on_win.after(GameMutation),
) )
.add_systems(Update, handle_watch_replay_button) .add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
.add_systems( .add_systems(
Update, Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(), (handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
@@ -277,6 +299,48 @@ fn refresh_replay_history_on_win(
/// resets the live game to the recorded deal and ticks through the /// resets the live game to the recorded deal and ticks through the
/// move list via [`crate::replay_playback`]; the /// move list via [`crate::replay_playback`]; the
/// [`crate::replay_overlay`] banner surfaces while playback runs. /// [`crate::replay_overlay`] banner surfaces while playback runs.
/// Copies [`LastSharedReplayUrl`] to the OS clipboard via `arboard`
/// and surfaces a confirmation toast. When no URL is in hand (no win
/// yet on a server-backed sync backend, local-only mode, or upload
/// failed) the button still acknowledges the click but explains why
/// the clipboard wasn't written. `arboard::Clipboard::new()` failures
/// are logged + surfaced as a generic "couldn't reach the clipboard"
/// toast rather than swallowed — they're rare but worth diagnosing.
fn handle_copy_share_link_button(
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
last_url: Res<LastSharedReplayUrl>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
let Some(url) = last_url.0.as_ref() else {
toast.write(InfoToastEvent(
"No share link yet \u{2014} win a game on a server-backed sync to upload one.".to_string(),
));
return;
};
match arboard::Clipboard::new() {
Ok(mut cb) => match cb.set_text(url.clone()) {
Ok(()) => {
toast.write(InfoToastEvent(format!("Copied: {url}")));
}
Err(e) => {
warn!("clipboard write failed: {e}");
toast.write(InfoToastEvent(
"Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(),
));
}
},
Err(e) => {
warn!("clipboard init failed: {e}");
toast.write(InfoToastEvent(
"Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(),
));
}
}
}
fn handle_watch_replay_button( fn handle_watch_replay_button(
mut commands: Commands, mut commands: Commands,
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>, buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
@@ -811,6 +875,19 @@ fn spawn_stats_screen(
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
// Copy share link only renders when a sharable URL is in
// hand. The button is intentionally absent (rather than
// disabled) when no upload has happened yet — keeps the
// action bar free of dead controls in the local-only and
// first-launch cases.
spawn_modal_button(
actions,
CopyShareLinkButton,
"Copy share link",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
StatsCloseButton, StatsCloseButton,
+49 -11
View File
@@ -29,7 +29,7 @@ use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
use crate::game_plugin::RecordingReplay; use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource}; use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
use crate::stats_plugin::{StatsResource, StatsStoragePath}; use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public resources // Public resources
@@ -57,6 +57,13 @@ pub struct PullTaskResult(pub Option<Result<SyncPayload, SyncError>>);
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>); struct PullTask(Option<Task<Result<SyncPayload, SyncError>>>);
/// Holds the in-flight winning-replay upload task so the polling
/// system can harvest the resulting share URL on the main thread
/// without blocking. `None` outside an active upload; `Some(task)`
/// from `GameWonEvent` until the response lands.
#[derive(Resource, Default)]
struct PendingReplayUpload(Option<Task<Result<String, SyncError>>>);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin struct // Plugin struct
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -94,12 +101,18 @@ impl Plugin for SyncPlugin {
.init_resource::<SyncStatusResource>() .init_resource::<SyncStatusResource>()
.init_resource::<PullTaskResult>() .init_resource::<PullTaskResult>()
.init_resource::<PullTask>() .init_resource::<PullTask>()
.init_resource::<PendingReplayUpload>()
.add_message::<ManualSyncRequestEvent>() .add_message::<ManualSyncRequestEvent>()
.add_message::<SyncCompleteEvent>() .add_message::<SyncCompleteEvent>()
.add_systems(Startup, start_pull) .add_systems(Startup, start_pull)
.add_systems( .add_systems(
Update, Update,
(poll_pull_result, handle_manual_sync_request, push_replay_on_win), (
poll_pull_result,
handle_manual_sync_request,
push_replay_on_win,
poll_replay_upload_result,
),
) )
.add_systems(Last, push_on_exit); .add_systems(Last, push_on_exit);
} }
@@ -282,6 +295,7 @@ fn push_replay_on_win(
provider: Res<SyncProviderResource>, provider: Res<SyncProviderResource>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
recording: Res<RecordingReplay>, recording: Res<RecordingReplay>,
mut pending: ResMut<PendingReplayUpload>,
) { ) {
for ev in wins.read() { for ev in wins.read() {
// Empty-recording guard mirrors `record_replay_on_win` — // Empty-recording guard mirrors `record_replay_on_win` —
@@ -300,15 +314,39 @@ fn push_replay_on_win(
recording.moves.clone(), recording.moves.clone(),
); );
let provider = provider.0.clone(); let provider = provider.0.clone();
AsyncComputeTaskPool::get() let task = AsyncComputeTaskPool::get()
.spawn(async move { .spawn(async move { provider.push_replay(&replay).await });
match provider.push_replay(&replay).await { // If a previous upload is still in flight, drop it — the most
Ok(()) => {} // recent win is the one whose share link the player will care
Err(SyncError::UnsupportedPlatform) => {} // about. Bevy's `Task` Drop cancels cooperatively.
Err(e) => warn!("replay upload failed: {e}"), pending.0 = Some(task);
} }
}) }
.detach();
/// Update-schedule system: harvests the upload task's result on the
/// main thread once it resolves. On success writes the share URL to
/// [`LastSharedReplayUrl`] so the Stats overlay's Copy button has
/// something to send to the clipboard. On `UnsupportedPlatform` (the
/// `LocalOnlyProvider` no-op path) clears the URL silently. Real
/// network / auth errors log a warn and clear the URL.
fn poll_replay_upload_result(
mut pending: ResMut<PendingReplayUpload>,
mut last_url: ResMut<LastSharedReplayUrl>,
) {
let Some(task) = pending.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
pending.0 = None;
match result {
Ok(url) => last_url.0 = Some(url),
Err(SyncError::UnsupportedPlatform) => last_url.0 = None,
Err(e) => {
warn!("replay upload failed: {e}");
last_url.0 = None;
}
} }
} }