diff --git a/Cargo.lock b/Cargo.lock index 66f5e87..6e701d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.15.5", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -313,6 +313,23 @@ dependencies = [ "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]] name = "arc-swap" version = "1.9.1" @@ -1972,6 +1989,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cmake" version = "0.1.58" @@ -2882,6 +2908,12 @@ dependencies = [ "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]] name = "etcetera" version = "0.8.0" @@ -4968,6 +5000,18 @@ dependencies = [ "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]] name = "objc2-audio-toolbox" version = "0.3.2" @@ -5065,6 +5109,19 @@ dependencies = [ "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]] name = "objc2-core-image" version = "0.2.2" @@ -5121,6 +5178,17 @@ dependencies = [ "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]] name = "objc2-link-presentation" version = "0.2.2" @@ -5129,7 +5197,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -6856,6 +6924,7 @@ dependencies = [ name = "solitaire_engine" version = "0.1.0" dependencies = [ + "arboard", "async-trait", "bevy", "chrono", @@ -9516,7 +9585,7 @@ dependencies = [ "libc", "ndk", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", diff --git a/Cargo.toml b/Cargo.toml index 316089c..aef152f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ dirs = "6" keyring = "4" keyring-core = "1" 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_sync = { path = "solitaire_sync" } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 3f6174f..446be01 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -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 `/replays/`. 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 + /// (`/replays/`). 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 { Err(SyncError::UnsupportedPlatform) } } @@ -101,7 +101,7 @@ impl SyncProvider for Box { 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 { (**self).push_replay(replay).await } } diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 5084fff..2d54180 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -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": "" }`; this method composes that + /// id with the configured base URL into the player-shareable + /// `/replays/` 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 { 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 { + 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)) } } diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 30e739a..b1e1dbe 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -21,6 +21,7 @@ tiny-skia = { workspace = true } ron = { workspace = true } dirs = { workspace = true } zip = { workspace = true } +arboard = { workspace = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index ecb114f..790c1cf 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -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); + +/// 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::() .insert_resource(LatestReplayPath(replay_path)) + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -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, Changed)>, + last_url: Res, + mut toast: MessageWriter, +) { + 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, Changed)>, @@ -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, diff --git a/solitaire_engine/src/sync_plugin.rs b/solitaire_engine/src/sync_plugin.rs index 6c02a2a..19e68c2 100644 --- a/solitaire_engine/src/sync_plugin.rs +++ b/solitaire_engine/src/sync_plugin.rs @@ -29,7 +29,7 @@ use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent}; use crate::game_plugin::RecordingReplay; use crate::progress_plugin::{ProgressResource, ProgressStoragePath}; use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource}; -use crate::stats_plugin::{StatsResource, StatsStoragePath}; +use crate::stats_plugin::{LastSharedReplayUrl, StatsResource, StatsStoragePath}; // --------------------------------------------------------------------------- // Public resources @@ -57,6 +57,13 @@ pub struct PullTaskResult(pub Option>); #[derive(Resource, Default)] struct PullTask(Option>>); +/// 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>>); + // --------------------------------------------------------------------------- // Plugin struct // --------------------------------------------------------------------------- @@ -94,12 +101,18 @@ impl Plugin for SyncPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_systems(Startup, start_pull) .add_systems( 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); } @@ -282,6 +295,7 @@ fn push_replay_on_win( provider: Res, game: Res, recording: Res, + mut pending: ResMut, ) { for ev in wins.read() { // Empty-recording guard mirrors `record_replay_on_win` — @@ -300,15 +314,39 @@ fn push_replay_on_win( recording.moves.clone(), ); let provider = provider.0.clone(); - AsyncComputeTaskPool::get() - .spawn(async move { - match provider.push_replay(&replay).await { - Ok(()) => {} - Err(SyncError::UnsupportedPlatform) => {} - Err(e) => warn!("replay upload failed: {e}"), - } - }) - .detach(); + let task = AsyncComputeTaskPool::get() + .spawn(async move { provider.push_replay(&replay).await }); + // If a previous upload is still in flight, drop it — the most + // recent win is the one whose share link the player will care + // about. Bevy's `Task` Drop cancels cooperatively. + pending.0 = Some(task); + } +} + +/// 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, + mut last_url: ResMut, +) { + 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; + } } }