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:
Generated
+72
-3
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user