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
+77
View File
@@ -74,6 +74,26 @@ pub struct StatsCell;
#[derive(Resource, Debug, Default, Clone)]
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`.
///
/// `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))
.init_resource::<SelectedReplayIndex>()
.insert_resource(LatestReplayPath(replay_path))
.init_resource::<LastSharedReplayUrl>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<ForfeitEvent>()
@@ -210,6 +231,7 @@ impl Plugin for StatsPlugin {
refresh_replay_history_on_win.after(GameMutation),
)
.add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
.add_systems(
Update,
(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
/// move list via [`crate::replay_playback`]; the
/// [`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(
mut commands: Commands,
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
@@ -811,6 +875,19 @@ fn spawn_stats_screen(
ButtonVariant::Secondary,
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(
actions,
StatsCloseButton,