Compare commits
6 Commits
22303c62ff
...
b129664344
| Author | SHA1 | Date | |
|---|---|---|---|
| b129664344 | |||
| 7d7c83ab28 | |||
| bd388fef26 | |||
| 272d31f851 | |||
| 6ce55646d8 | |||
| 432061c3ec |
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "jti",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||||
|
}
|
||||||
+81
-5
@@ -1,9 +1,9 @@
|
|||||||
# Solitaire Quest — Architecture Document
|
# Solitaire Quest — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.1
|
> **Version:** 1.3
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-04-29
|
> **Last Updated:** 2026-05-12
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ solitaire_quest/
|
|||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||||
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
||||||
└── solitaire_app/ # Main binary entry point
|
└── solitaire_app/ # Main binary entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,6 +161,20 @@ Owns:
|
|||||||
- Daily challenge seed generation
|
- Daily challenge seed generation
|
||||||
- Leaderboard management
|
- Leaderboard management
|
||||||
|
|
||||||
|
### `solitaire_wasm`
|
||||||
|
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||||
|
|
||||||
|
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||||
|
|
||||||
|
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
|
||||||
|
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
|
||||||
|
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
|
||||||
|
|
||||||
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`.
|
||||||
|
|
||||||
@@ -261,6 +276,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
|||||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||||
|
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
|
||||||
|
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
|
||||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||||
| `HelpPlugin` | H | Help / controls overlay |
|
| `HelpPlugin` | H | Help / controls overlay |
|
||||||
| `PausePlugin` | Esc | Pause and resume |
|
| `PausePlugin` | Esc | Pause and resume |
|
||||||
@@ -365,10 +382,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
|
|||||||
```rust
|
```rust
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SyncProvider: Send + Sync {
|
pub trait SyncProvider: Send + Sync {
|
||||||
|
// Required — must be implemented by every backend:
|
||||||
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
||||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
||||||
fn backend_name(&self) -> &'static str;
|
fn backend_name(&self) -> &'static str;
|
||||||
fn is_authenticated(&self) -> bool;
|
fn is_authenticated(&self) -> bool;
|
||||||
|
|
||||||
|
// Optional — all have default no-op / empty implementations:
|
||||||
|
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
|
||||||
|
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError>;
|
||||||
|
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
|
||||||
|
// so LocalOnlyProvider silently no-ops the push-on-win path.
|
||||||
|
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -454,6 +483,24 @@ CREATE TABLE leaderboard (
|
|||||||
recorded_at TEXT NOT NULL,
|
recorded_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id)
|
PRIMARY KEY (user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- migrations/002_replays.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS replays (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
seed INTEGER NOT NULL,
|
||||||
|
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||||
|
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||||
|
time_seconds INTEGER NOT NULL,
|
||||||
|
final_score INTEGER NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||||
|
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||||
|
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Request Lifecycle
|
### Request Lifecycle
|
||||||
@@ -579,12 +626,25 @@ pub struct AchievementRecord {
|
|||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||||
|
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
|
||||||
|
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
|
pub color_blind_mode: bool, // blue tint on red suits
|
||||||
|
pub high_contrast_mode: bool, // boosted luminance for low-vision users
|
||||||
|
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
|
||||||
|
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
pub width: u32, // logical pixels
|
||||||
|
pub height: u32,
|
||||||
|
pub x: i32, // physical pixels, top-left corner
|
||||||
|
pub y: i32,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -600,7 +660,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
|
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
|
||||||
|
|
||||||
### Sync
|
### Sync
|
||||||
|
|
||||||
@@ -617,6 +677,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
||||||
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
||||||
|
|
||||||
|
### Replays
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
|
||||||
|
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
|
||||||
|
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
|
||||||
|
|
||||||
|
### Web Replay Player
|
||||||
|
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||||
|
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||||
|
|
||||||
### Account Management
|
### Account Management
|
||||||
|
|
||||||
| Method | Path | Auth | Body | Response |
|
| Method | Path | Auth | Body | Response |
|
||||||
@@ -945,6 +1020,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
|||||||
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
||||||
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
||||||
| Token expiry | Access: 24h, Refresh: 30d |
|
| Token expiry | Access: 24h, Refresh: 30d |
|
||||||
|
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
|
||||||
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
||||||
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
||||||
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
||||||
|
|||||||
@@ -6,6 +6,89 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.23.0] — 2026-05-12
|
||||||
|
|
||||||
|
Phase 8 sync UI: the self-hosted-server connection flow is now fully
|
||||||
|
playable end-to-end. Players can open a Connect modal from Settings,
|
||||||
|
enter a server URL + credentials, log in or register, and see the
|
||||||
|
sync-status section update live. Token expiry auto-reopens the modal.
|
||||||
|
Account deletion ships a two-click destroy flow. Server deployment
|
||||||
|
artifacts (Dockerfile + docker-compose) let self-hosters spin up in one
|
||||||
|
command.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sync setup modal — Connect / Disconnect flow** (`432061c`).
|
||||||
|
New `SyncSetupPlugin` (`solitaire_engine/src/sync_setup_plugin.rs`)
|
||||||
|
provides the full server-connection UI. Three tab-stopped text fields
|
||||||
|
(URL, Username, Password) handle keyboard input via `MessageReader<KeyboardInput>`
|
||||||
|
with focus cycling on Tab. "Log In" and "Register" buttons each spawn an
|
||||||
|
async `AsyncComputeTaskPool` task that calls the new
|
||||||
|
`SolitaireServerClient::login()` / `::register()` methods; `poll_auth_task`
|
||||||
|
harvests the result, stores tokens via `store_tokens()`, hot-swaps
|
||||||
|
`SyncProviderResource` to the new server backend, fires
|
||||||
|
`ManualSyncRequestEvent` to pull immediately, and closes the modal.
|
||||||
|
An inline `SyncAuthError` label displays credential errors without a
|
||||||
|
toast. The modal is idempotent (`existing.is_empty()` guard) — safe
|
||||||
|
to open programmatically.
|
||||||
|
- **`SyncConfigureRequestEvent`, `SyncLogoutRequestEvent`,
|
||||||
|
`DeleteAccountRequestEvent`** (`432061c`). Three new engine events
|
||||||
|
wire the Settings buttons → plugin handlers. `SyncConfigureRequestEvent`
|
||||||
|
opens the setup modal; `SyncLogoutRequestEvent` disconnects and resets
|
||||||
|
`SyncProviderResource` to `LocalOnlyProvider`; `DeleteAccountRequestEvent`
|
||||||
|
opens the deletion confirmation modal.
|
||||||
|
- **Settings sync section — dynamic backend UI** (`432061c`).
|
||||||
|
`sync_row()` in `SettingsPlugin` now takes `backend: &SyncBackend` and
|
||||||
|
renders conditionally: `Local` → "Connect" button; `SolitaireServer` →
|
||||||
|
username label + "Sync Now" + "Disconnect" + "Delete Account". Three new
|
||||||
|
`SettingsButton` discriminants (`ConnectSync` tab 91, `DisconnectSync`
|
||||||
|
tab 92, `DeleteAccount` tab 93) feed into a new `handle_sync_buttons`
|
||||||
|
system extracted from `handle_settings_buttons` to stay within Bevy's
|
||||||
|
16-parameter system limit.
|
||||||
|
- **`SolitaireServerClient::login()` and `::register()`** (`432061c`).
|
||||||
|
Both POST to `/api/auth/login` and `/api/auth/register` respectively.
|
||||||
|
Private helper `extract_auth_tokens` parses `{ access_token, refresh_token }`.
|
||||||
|
409 CONFLICT → "username already taken"; 401/403 → "invalid credentials";
|
||||||
|
400 → server message echoed to the player.
|
||||||
|
- **Re-auth prompt on token expiry** (`6ce5564`).
|
||||||
|
`poll_pull_result` in `SyncPlugin` now fires `InfoToastEvent("Session
|
||||||
|
expired — please reconnect")` + `SyncConfigureRequestEvent` when the
|
||||||
|
pull task resolves to `SyncError::Auth(_)`. Because the modal is
|
||||||
|
idempotent the re-open is safe to trigger from any system path.
|
||||||
|
- **Server deployment artifacts** (`6ce5564`).
|
||||||
|
`solitaire_server/Dockerfile`: multi-stage build (`rust:1.95-slim` →
|
||||||
|
`debian:bookworm-slim`); copies `.sqlx` offline cache so `SQLX_OFFLINE=true`
|
||||||
|
succeeds without a live database at build time; exposes port 8080.
|
||||||
|
`solitaire_server/docker-compose.yml`: single-service compose file;
|
||||||
|
`db-data` volume at `/app/data`; `DATABASE_URL` and `JWT_SECRET` from
|
||||||
|
environment; HTTP health-check via `wget`. `solitaire_server/.env.example`:
|
||||||
|
documents all required variables with generation hint (`openssl rand -hex 32`).
|
||||||
|
- **Account deletion flow** (`272d31f`).
|
||||||
|
"Delete Account" in Settings fires `DeleteAccountRequestEvent` →
|
||||||
|
`SyncSetupPlugin::open_delete_confirm_modal` spawns a danger-red
|
||||||
|
confirmation modal with "Cancel" and "Delete Forever" buttons.
|
||||||
|
"Delete Forever" submits an async `PendingDeleteTask` that calls
|
||||||
|
`SyncProvider::delete_account()`; `poll_delete_task` on Ok fires
|
||||||
|
`SyncLogoutRequestEvent` + a success toast; on Err shows an error toast
|
||||||
|
and leaves the modal open. Two-click destroy pattern — no accidental
|
||||||
|
account deletion possible.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **`SyncAuthResultEvent`** (`432061c`). Defined but never emitted or
|
||||||
|
consumed; removed as dead code.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- Tests: **1300+ passing** / 0 failing
|
||||||
|
- Clippy: clean
|
||||||
|
- Crates touched: `solitaire_data` (sync_client), `solitaire_engine`
|
||||||
|
(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),
|
||||||
|
`solitaire_app` (lib.rs), `solitaire_server` (Dockerfile,
|
||||||
|
docker-compose.yml, .env.example [new])
|
||||||
|
|
||||||
## [0.22.0] — 2026-05-08
|
## [0.22.0] — 2026-05-08
|
||||||
|
|
||||||
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
Adds difficulty-tier game selection, Android JNI bridges for keystore and
|
||||||
|
|||||||
+128
-304
@@ -1,338 +1,162 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-08 — **v0.21.8 tagged at `c50eaf8`**;
|
**Last updated:** 2026-05-12 — ARCHITECTURE.md updated to v1.3 (all 8 Phase 8 gaps closed);
|
||||||
nine post-cut commits on master. Push pending.
|
`SESSION_HANDOFF.md` updated. Push pending.
|
||||||
|
|
||||||
v0.21.8 closes the last optional polish items in the B-2
|
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||||
replay screen-takeover arc: **notch-label centering** (middle
|
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||||
three scrub-bar labels now centred on their notch ticks via the
|
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
|
||||||
CSS `translateX(-50%)` pattern for Bevy 0.18 UI) and **WIN
|
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
|
||||||
MOVE HC legibility** (lime stays lime under HC mode via the
|
and full server integration tests.
|
||||||
extended `HighContrastBackground::with_hc` constructor and a
|
|
||||||
new `STATE_SUCCESS_HC` brighter-lime constant). The replay
|
|
||||||
overlay arc is now fully closed with no known open items.
|
|
||||||
|
|
||||||
Full v0.21.8 detail lives in `CHANGELOG.md` § [0.21.8]. This
|
---
|
||||||
file from here on focuses on what's *open* post-cut and how to
|
|
||||||
resume.
|
|
||||||
|
|
||||||
## Status at pause
|
## Current state
|
||||||
|
|
||||||
- **HEAD locally:** `f281425` (Android Keystore JNI).
|
- **HEAD locally:** `bd388fe` (docs: CHANGELOG Phase 8 entry).
|
||||||
Docs ride on top; push pending.
|
- **HEAD on origin:** `272d31f` (feat: account deletion — last pushed commit).
|
||||||
- **HEAD on origin:** `395a322` (double-tap commit — last pushed).
|
- **Working tree:** `ARCHITECTURE.md` + `SESSION_HANDOFF.md` modified, uncommitted.
|
||||||
- **Working tree:** clean (docs uncommitted). No WIP outstanding.
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
- **`artwork/` directory:** still untracked. Intentional.
|
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
- **Tags on origin:** `v0.9.0` through `v0.22.0`.
|
||||||
clean.
|
|
||||||
- **Tests:** **1292 passing / 0 failing** across the workspace.
|
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.21.8`.
|
|
||||||
- **Android:** APK verified booting on Pixel_7 AVD (Android 14,
|
|
||||||
x86_64). All desktop-only systems (handle_fullscreen) now gated.
|
|
||||||
See Phase Android punch list for remaining work.
|
|
||||||
|
|
||||||
## Since the v0.21.8 cut
|
---
|
||||||
|
|
||||||
Seven commits since the v0.21.8 tag:
|
## What shipped in Phase 8 (432061c – bd388fe)
|
||||||
- `a449f60` — Stats Prev/Next selector spawn site
|
|
||||||
- `202a64d` — Android launch fixes (android_main, resize_constraints,
|
|
||||||
apply_smart_default_window_size) — **closes APK launch verification**
|
|
||||||
- `16242e6` — Ignore .idea/ IDE files
|
|
||||||
- `395a322` — double-tap auto-move for touch input
|
|
||||||
- `0cb1587` — Play-by-Seed dialog + HomeMode card
|
|
||||||
- `2062bd0` — 75 new challenge seeds + gen_seeds binary
|
|
||||||
- `45436d0` — gate handle_fullscreen to non-Android
|
|
||||||
- `2c822ba` — JNI clipboard bridge for Android Stats share-link
|
|
||||||
- `f281425` — Android Keystore AES-GCM token storage via JNI
|
|
||||||
|
|
||||||
CHANGELOG + SESSION_HANDOFF docs ride on top; push pending.
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
|
||||||
|
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
|
||||||
|
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
|
||||||
|
| `bd388fe` | CHANGELOG v0.23.0 documentation |
|
||||||
|
|
||||||
Open next-step menu:
|
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||||
1. **Phase 8 (sync)** — the biggest open arc. Local storage
|
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
|
||||||
scaffolding, self-hosted Axum server, GPGS stub.
|
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
|
||||||
2. **Android follow-ups** — JNI ClipboardManager, Android Keystore,
|
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
|
||||||
GPGS. Launch verification and double-tap both closed; these
|
- DB migration 002: `replays` table + two indexes
|
||||||
are the remaining Phase Android items.
|
- Full server integration tests for replay endpoints
|
||||||
3. **Move Log auto-scroll** — only relevant if the panel
|
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
|
||||||
row count grows beyond the current 5-row fixed window.
|
- Stats panel "Copy Share Link" button reads `share_url` from replay history
|
||||||
|
|
||||||
## Open punch list
|
---
|
||||||
|
|
||||||
### Phase Android (build + persistence shipped; runtime gaps remain)
|
## Open punch list (ordered by priority)
|
||||||
|
|
||||||
- *APK launch verification — closed 2026-05-08 by `202a64d`.*
|
### 1. Documentation debt (no code)
|
||||||
Three fixes shipped: `android_main` export (missing NativeActivity
|
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
|
||||||
entry point), `resize_constraints` gated to non-Android (max=0
|
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
|
||||||
panic), `apply_smart_default_window_size` gated to non-Android
|
- [x] SESSION_HANDOFF.md update — this file
|
||||||
(clamp panic on zero-dimension window event). Verified booting on
|
|
||||||
Pixel_7 AVD (Android 14, x86_64, SwiftShader Vulkan), 2+ min
|
### 2. Leaderboard wiring gaps
|
||||||
runtime without crash. B0004 ECS hierarchy warnings remain
|
- **Best-score auto-post missing.** `POST /api/sync/push` merges stats/achievements/
|
||||||
(non-fatal; entity parent/child component mismatch); investigate
|
progress but never touches the `leaderboard` table. Players who opt in never
|
||||||
if they surface gameplay bugs.
|
have their `best_time_secs` / `best_score` updated automatically. Fix: update
|
||||||
- *Double-tap auto-move — closed 2026-05-08 by `395a322`.*
|
the leaderboard row inside the server's sync push handler (or on `GameWonEvent`
|
||||||
`handle_double_tap` fires `MoveRequestEvent` on two rapid
|
via a new async task in `sync_plugin`).
|
||||||
`TouchPhase::Ended` events within 0.5 s. Prefers foundation;
|
- **Display name = username.** `handle_opt_in_button` uses the `SyncBackend`
|
||||||
falls back to tableau stack move. Fires `MoveRejectedEvent` when
|
username as the leaderboard display name. Consider adding
|
||||||
no legal destination exists. System runs before `touch_end_drag`
|
`leaderboard_display_name: Option<String>` to `Settings` for players who
|
||||||
in the chain so drag state is readable.
|
want a different public identity.
|
||||||
- *F11 fullscreen gate — closed 2026-05-08 by `45436d0`.*
|
|
||||||
`handle_fullscreen` and its `MonitorSelection`/`WindowMode`
|
### 3. Security hardening
|
||||||
imports are `#[cfg(not(target_os = "android"))]`-gated. The
|
- **Refresh token rotation.** `POST /api/auth/refresh` returns only a new
|
||||||
`add_systems` call is a separate statement (not mid-chain).
|
access token; the refresh token never rotates. Standard mitigation: issue a
|
||||||
- *JNI ClipboardManager bridge — closed 2026-05-08 by `2c822ba`.*
|
new refresh token on each call and invalidate the old one (needs a
|
||||||
`android_clipboard::set_text(url)` calls `ClipboardManager` via
|
`last_refresh_token` column or a separate table).
|
||||||
JNI. Stats share-link button now writes to the clipboard with a
|
- **Sync endpoint rate limiting.** Only `/api/auth/*` has `tower-governor`;
|
||||||
"Copied: {url}" toast; falls back to "Share link: {url}" on JNI
|
`/api/sync/push` (1 MB body) has no per-user throttle.
|
||||||
error. Requires AVD functional test (see verification steps in
|
|
||||||
the approved plan).
|
### 4. Android validation
|
||||||
- *Android Keystore for credentials — closed 2026-05-08 by `f281425`.*
|
- **Android Keystore functional test** — JNI AES-GCM code ships (`f281425`) but
|
||||||
`android_keystore` module: AES-256/GCM/NoPadding device-bound key,
|
no AVD round-trip test has been run. Required before Phase 8 sync goes live on
|
||||||
tokens serialised to JSON and stored atomically at
|
|
||||||
`{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+tag]`.
|
|
||||||
`auth_tokens.rs` Android stubs now delegate to it. Key
|
|
||||||
invalidation (biometric reset) → `TokenError::KeychainUnavailable`.
|
|
||||||
Requires AVD functional test before Phase 8 sync goes live on
|
|
||||||
Android.
|
Android.
|
||||||
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
|
- **JNI clipboard functional test** — same status (`2c822ba`). Note: `adb tap`
|
||||||
panic doesn't affect the APK on disk but produces noisy stderr.
|
doesn't work in headless AVD (see memory); requires a touch-gesture path.
|
||||||
Either upstream a cargo-apk fix or document `--lib` as
|
- **`cargo apk build --lib` noisy stderr** — post-sign panic doesn't affect the
|
||||||
canonical in the runbook.
|
APK but pollutes CI output. Document `--lib` as canonical or upstream a fix.
|
||||||
|
|
||||||
### Visual-identity follow-ups (post-v0.21.0)
|
### 5. Feature completeness
|
||||||
|
- **Theme importer UI.** `import_theme()` (Phase 7, `theme/importer.rs`) is
|
||||||
|
complete but has no Settings button trigger. Players must copy theme files
|
||||||
|
manually.
|
||||||
|
- **`mirror_achievement` decision.** `SyncProvider` has this method with a
|
||||||
|
no-op default; `SolitaireServerClient` never overrides it, no server endpoint
|
||||||
|
exists. Either implement (`POST /api/achievements/mirror` + client call on
|
||||||
|
`AchievementUnlockedEvent`) or delete from the trait.
|
||||||
|
- **WASM build script.** `web/pkg/` contains compiled WASM committed to git.
|
||||||
|
Need a `build_wasm.sh` or Makefile target documenting the `wasm-pack build`
|
||||||
|
invocation to regenerate it.
|
||||||
|
- **Server password reset.** No admin endpoint or CLI tool for resetting a
|
||||||
|
user's password. Self-hosters have no recovery path short of direct SQLite
|
||||||
|
edits.
|
||||||
|
|
||||||
The visual-identity arc is effectively complete: token system,
|
### 6. Testing gaps
|
||||||
chrome migration, splash boot screen, replay-overlay banner,
|
- **Server 401 → refresh → retry path** — the `pull`/`push` retry logic in
|
||||||
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
|
`SolitaireServerClient` has no integration test.
|
||||||
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
|
- **WASM winning-replay step-through** — current tests cover 2 stock clicks;
|
||||||
|
a test stepping through a full winning sequence would catch
|
||||||
|
`GameState`/`ReplayMove` compatibility regressions.
|
||||||
|
|
||||||
- *Replay-overlay screen-takeover redesign — closed 2026-05-08
|
---
|
||||||
across 13 commits (v0.21.4–v0.21.7).* The full mockup
|
|
||||||
(`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
|
|
||||||
banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
|
|
||||||
MOVE scrub-bar marker (post-v0.21.3), playback controls /
|
|
||||||
Space accelerator (post-v0.21.3), scrub notches + labels +
|
|
||||||
keybind footer + ESC / ← / → accelerators + HC border
|
|
||||||
(v0.21.5), Move Log panel + HC scrub track + continuous
|
|
||||||
scrub (v0.21.6), and full-screen 50 % opacity dim layer
|
|
||||||
(v0.21.7). Every major B-2 sub-piece is now closed. The
|
|
||||||
only remaining items are minor polish: notch-label centering
|
|
||||||
and WIN MOVE HC contrast bump (see Open next-step menu).*
|
|
||||||
- *Floating `MOVE N/M` chip above the focused card during
|
|
||||||
playback — closed 2026-05-08 by `2fb2d63`.* World-space
|
|
||||||
`Text2d` entity sibling to the banner overlay; uses the same
|
|
||||||
`LayoutResource` pile coordinates so it survives window
|
|
||||||
resizes without UI/camera math.
|
|
||||||
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
|
|
||||||
Daily-challenge-expiry toast fires once per `daily.date` when
|
|
||||||
within 30 min of UTC midnight reset and today is incomplete.
|
|
||||||
`ToastVariant` is now fully load-bearing (every variant has at
|
|
||||||
least one real driver). Future Warning drivers can either reuse
|
|
||||||
the generic `WarningToastEvent(String)` carrier or add their
|
|
||||||
own domain message + `animation_plugin` handler.
|
|
||||||
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
|
|
||||||
`MoveRejectedEvent` now fires a 2-second pink-bordered
|
|
||||||
"Invalid move" toast as the third leg of the
|
|
||||||
audio + visual + text rejection-feedback stool.
|
|
||||||
- *High-contrast accessibility mode — closed 2026-05-08 by
|
|
||||||
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
|
|
||||||
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
|
|
||||||
dynamic-paint rollout (`c153363`).* Card text rendering plus
|
|
||||||
8 static-border chrome surfaces (modal scaffold, tooltip,
|
|
||||||
onboarding key chips, help panel key chips, stats panel
|
|
||||||
cells, home Level/XP/Score row, home mode buttons, home
|
|
||||||
mode-hotkey chips, 4 settings panel surfaces) all boost
|
|
||||||
borders to `BORDER_SUBTLE_HC` under HC via the
|
|
||||||
`HighContrastBorder` marker. The previously-carved-out
|
|
||||||
dynamic-paint sites are now also covered: HUD action buttons
|
|
||||||
and modal buttons take the same marker (their paint cycles
|
|
||||||
only mutate `BackgroundColor`, so no race); the radial menu
|
|
||||||
rim folds HC into its per-frame spawn via
|
|
||||||
`radial_rim_outline` so the focused rim boosts to
|
|
||||||
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
|
|
||||||
hierarchy that naive marker substitution would invert).
|
|
||||||
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
|
|
||||||
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
|
|
||||||
card animations; `pulse_splash_cursor` skips the per-frame
|
|
||||||
pulse multiplier; `spawn_splash` skips the scanline overlay
|
|
||||||
entirely. Future scope: gate any future card-lift z-bump
|
|
||||||
animation, warning-chip pulse (when one materialises).
|
|
||||||
|
|
||||||
### Carried forward from v0.19.0
|
## ARCHITECTURE.md gaps (for the update pass)
|
||||||
|
|
||||||
- *App icon round — closed 2026-05-08 by `3eb3a26` + `716a025`.*
|
Items missing from the doc:
|
||||||
Runtime `Window::icon` wired (Linux/macOS/Windows); 9-size
|
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
|
||||||
PNG hierarchy at `assets/icon/icon_<size>.png` covers Linux
|
2. Replay API endpoints (§9 API Reference — 3 new routes)
|
||||||
hicolor + downstream `.icns`/`.ico` packaging needs. The
|
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
|
||||||
`.ico` and `.icns` bundle-format files themselves are *not*
|
4. `SyncProvider` trait: 6 added methods
|
||||||
generated — both would need new crate deps (`ico` and
|
5. Theme system in Bevy plugin table (§5)
|
||||||
`icns` respectively) and only matter at app-bundle time
|
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
|
||||||
(cargo-bundle / packaging), not at `cargo run`. Open if the
|
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
|
||||||
project later ships as a packaged macOS / Windows app.
|
`selected_background`
|
||||||
|
7. DB migration 002 (§7)
|
||||||
|
8. Update "Last Updated" date
|
||||||
|
|
||||||
### Other small candidates
|
---
|
||||||
|
|
||||||
- *Play-by-Seed dialog — closed 2026-05-08 by `0cb1587`.*
|
## Process notes
|
||||||
`PlayBySeedPlugin` adds a numeric-input modal with async solver
|
|
||||||
preview (debounced 500 ms). `HomeMode::PlayBySeed` card fires
|
|
||||||
`StartPlayBySeedRequestEvent`. 5 unit tests. 75 new verified-win
|
|
||||||
seeds (`2062bd0`) expand `CHALLENGE_SEEDS` via the new
|
|
||||||
`solitaire_assetgen::gen_seeds` binary.
|
|
||||||
- *Prev/Next selector chips spawn site — closed 2026-05-08 by
|
|
||||||
`a449f60`.* `ReplayPrevButton` / `ReplayNextButton` /
|
|
||||||
`ReplaySelectorCaption` / `ReplaySelectorDetail` now spawn in
|
|
||||||
`spawn_stats_screen` as a compact chip row above the Watch
|
|
||||||
Replay action. The Shareable badge is in the detail line.
|
|
||||||
The click handler and repaint systems were already live since
|
|
||||||
v0.19.0; this was purely the missing spawn site.
|
|
||||||
- **Toast queue / immediate unification.** The two toast paths
|
|
||||||
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
|
|
||||||
for fire-and-forget) now share visual treatment but remain
|
|
||||||
separate functions because they serve different temporal
|
|
||||||
needs (sequential vs. parallel). If overlap becomes a UX
|
|
||||||
issue, merge into one queue with priority lanes.
|
|
||||||
|
|
||||||
### Process notes
|
- **Commit attribution:** use `funman300` as git user. Co-author line:
|
||||||
|
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||||
|
- **Commit format:** `type(scope): description` per CLAUDE.md §7.
|
||||||
|
- **Never commit without:** `cargo test --workspace` passing + clippy clean.
|
||||||
|
- **Sub-agents** stage/verify only; orchestrator commits.
|
||||||
|
- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in
|
||||||
|
repo. Clean up references or commit the file.
|
||||||
|
- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete
|
||||||
|
artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this"
|
||||||
|
follow-ups in v0.21.0 all had this shape.
|
||||||
|
|
||||||
- **The desktop-adaptation spec is the canonical reference for
|
---
|
||||||
geometry decisions** when porting any future plugin. Read
|
|
||||||
`docs/ui-mockups/desktop-adaptation.md` first; apply the
|
|
||||||
universal rules to every surface; consult the per-screen
|
|
||||||
table for the priority surfaces. The 9 missing-plugin screens
|
|
||||||
(splash now ported; eight remaining) inherit the universal
|
|
||||||
rules without dedicated guidance.
|
|
||||||
- **Stitch `generate_variants` is unreliable for layout-only
|
|
||||||
adaptation prompts** as of 2026-05-07. The first call timed
|
|
||||||
out and no variant ever landed in `list_screens`. If a future
|
|
||||||
session wants visual desktop mockups, prefer
|
|
||||||
`generate_screen_from_text` with a fresh narrow prompt per
|
|
||||||
screen rather than `generate_variants` against existing
|
|
||||||
mobile screens.
|
|
||||||
- **Token-port pattern.** v0.20.0's chrome-migration commits
|
|
||||||
set a reusable shape for "centralised design system applied
|
|
||||||
across N plugins":
|
|
||||||
1. Constants module (`ui_theme.rs`) is the source of truth.
|
|
||||||
2. Const sites that can't call `Alpha::with_alpha` (not yet
|
|
||||||
`const` on stable) use a literal RGB matching the token,
|
|
||||||
with a unit test pinning the RGB to the token (e.g.
|
|
||||||
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
|
|
||||||
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
|
|
||||||
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT` ↔
|
|
||||||
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
|
|
||||||
promoted const re-exported from one plugin and imported
|
|
||||||
by the other — replaces "kept in sync" doc comments with a
|
|
||||||
compile-time invariant.
|
|
||||||
4. Domain colours (suit pips, card faces, lerp helpers) stay
|
|
||||||
as literals with a comment naming the rationale; only UI
|
|
||||||
chrome routes through tokens.
|
|
||||||
- **`SplashFadable` scaffolding pattern** (introduced in
|
|
||||||
`cacb19c`). Any future overlay that needs to fade `N >> 3`
|
|
||||||
elements together should follow the same shape: one tiny
|
|
||||||
marker carrying the full-alpha base colour, one global query
|
|
||||||
that lerps every marker's alpha each frame, no per-element
|
|
||||||
query plumbing. Cleanly outscales the `Without<X>, Without<Y>`
|
|
||||||
query exclusion pattern that the old splash was hitting at
|
|
||||||
three siblings.
|
|
||||||
|
|
||||||
### Canonical remote
|
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
|
||||||
Always push there. As of v0.21.0 origin matches local; the next
|
|
||||||
push happens when post-cut work accumulates and is ready to roll
|
|
||||||
into a v0.21.1 / v0.22.0 cut.
|
|
||||||
|
|
||||||
### Design direction (Terminal — base16-eighties)
|
|
||||||
|
|
||||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
|
||||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
|
||||||
16 px edge margins, 8 px card radius.
|
|
||||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` /
|
|
||||||
`#2a2a2a` / `#353535`), brick-red primary CTA (`#a54242` —
|
|
||||||
swapped from cyan `#6fc2ef` in v0.21.0 commit `a292a7e`), lime
|
|
||||||
success (`#acc267`), gold warning (`#ddb26f`), pink error /
|
|
||||||
suit-red (`#fb9fb1`), lavender celebration (`#e1a3ee`), teal
|
|
||||||
info (`#12cfc0`).
|
|
||||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`.
|
|
||||||
Outlined glyphs for diamonds & clubs are *always on*; the
|
|
||||||
Settings "color-blind mode" toggle swaps red → lime `#acc267`
|
|
||||||
(was red → cyan pre-v0.21.0; lime is the next-best non-red
|
|
||||||
base16-eighties accent now that the primary itself is red).
|
|
||||||
- **Card glyphs render upright in both corners** — no 180°
|
|
||||||
inverted-corner-indicator rotation. Single-orientation
|
|
||||||
digital play doesn't benefit from the traditional flip-
|
|
||||||
readback convention. `design-system.md` § Game Cards
|
|
||||||
documents this deliberate deviation.
|
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
Working directory: <Rusty_Solitaire clone path>.
|
||||||
Branch: master. v0.21.8 is tagged at c50eaf8 (cut 2026-05-08,
|
Branch: master. v0.23.0 is the current version (HEAD locally: bd388fe).
|
||||||
replay-overlay polish). Seven post-cut commits are on master (see
|
Phase 8 sync is fully shipped. ARCHITECTURE.md is now v1.3 (all Phase 8 gaps closed).
|
||||||
"Since the v0.21.8 cut" above); push of the last four pending.
|
Push to origin pending (bd388fe + ARCHITECTURE.md + SESSION_HANDOFF.md commits).
|
||||||
v0.21.7 stays at da3e542, v0.21.6 at f63db76, v0.21.5 at a2432df,
|
|
||||||
v0.21.4 at 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b,
|
|
||||||
v0.21.1 at daa655a, v0.21.0 at 04f9bf9.
|
|
||||||
Working tree: uncommitted CHANGELOG + SESSION_HANDOFF docs; push
|
|
||||||
pending. See CHANGELOG.md § [0.21.9] for full detail.
|
|
||||||
|
|
||||||
State: HEAD locally — see `git rev-parse HEAD`. Workspace
|
READ FIRST (in order):
|
||||||
tests: 1292 passing / 0 failing. Clippy clean.
|
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [0.21.9] section has the pending-cut items
|
2. CHANGELOG.md — [0.23.0] section has the full Phase 8 detail
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-3.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. ARCHITECTURE.md — v1.3, fully up to date
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. docs/ui-mockups/ — design system + mockup library
|
||||||
6. docs/ui-mockups/ — design system + 24-mockup library +
|
6. docs/android/ — Android setup + build runbook
|
||||||
desktop-adaptation.md (the rules-based
|
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
companion to the mockups; read this
|
|
||||||
before any plugin port)
|
|
||||||
7. docs/android/* — Android setup + build runbook
|
|
||||||
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
|
||||||
— saved feedback / project context
|
|
||||||
(machine-local; may be missing on a
|
|
||||||
fresh machine)
|
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
OPEN WORK (in priority order):
|
||||||
A. Android follow-ups — JNI ClipboardManager bridge (arboard
|
B. Leaderboard best-score auto-post (server sync handler + optional
|
||||||
has no Android backend), Android Keystore (blocked on Phase 8).
|
GameWonEvent path in sync_plugin)
|
||||||
Launch verification + double-tap are closed.
|
C. Refresh token rotation (server auth handler + new column/table)
|
||||||
B. Phase 8 (sync) — local storage scaffolding, self-hosted
|
D. Android AVD functional tests (Keystore + clipboard)
|
||||||
Axum server, `SolitaireServerClient` impl. The biggest open
|
E. Theme importer UI button in Settings
|
||||||
arc by scope; rolls up Android dependencies (Keystore,
|
F. mirror_achievement: decide + implement or remove from trait
|
||||||
ClipboardManager).
|
|
||||||
C. Play-by-Seed polish — the dialog is functional but has no
|
|
||||||
visual preview of the solver verdict in the UI yet; the
|
|
||||||
HomeMode card is wired but the dialog spawn site and verdict
|
|
||||||
display could use a second pass.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
Ask which to start. All are independent; any is a valid next arc.
|
||||||
- Use the system git config (already correct).
|
|
||||||
- When attributing playtester feedback in commits/docs, use
|
|
||||||
"Quat" not "Rhys" (saved feedback memory).
|
|
||||||
- Sub-agents stage + verify only; orchestrator commits.
|
|
||||||
- Every commit must pass build / clippy / test before pushing.
|
|
||||||
- Push to GitHub (origin) — gh auth setup-git wired on
|
|
||||||
primary dev box; verify on laptop before first push.
|
|
||||||
- Token-port pattern: when migrating tokens, walk every
|
|
||||||
concrete artifact downstream of the token (PNG textures,
|
|
||||||
embedded SVGs, hardcoded literals, comment color names),
|
|
||||||
not just the token name. v0.21.0 surfaced three "the
|
|
||||||
migration walked past this" follow-ups that all matched
|
|
||||||
this shape — codified here so future similar work can
|
|
||||||
pattern-match instead of rediscovering.
|
|
||||||
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
|
|
||||||
visibility fix (`4d48cad`) implemented an invariant that
|
|
||||||
had been declared in a module doc comment but was never
|
|
||||||
enforced in code. When future work touches a module with
|
|
||||||
a "this does X" doc comment, verify the code actually does
|
|
||||||
X and add a test if not. Two layers, two checks.
|
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–C. Don't pick unilaterally.
|
|
||||||
Note: every remaining option is multi-session by nature (A is
|
|
||||||
gated on Android tooling; B and C are explicitly multi-session
|
|
||||||
arcs). A fresh session is a better fit for any of them than the
|
|
||||||
tail of a long working stretch.
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ use solitaire_engine::{
|
|||||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||||
SelectionPlugin, SettingsPlugin,
|
SelectionPlugin, SettingsPlugin,
|
||||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
WinSummaryPlugin,
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
@@ -193,6 +193,7 @@ pub fn run() {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
|
.add_plugins(SyncSetupPlugin)
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
|
|||||||
@@ -83,18 +83,96 @@ impl SolitaireServerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authenticate with a username + password and return `(access_token, refresh_token)`.
|
||||||
|
///
|
||||||
|
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||||
|
/// The client's `username` field is used as the credential — the caller must
|
||||||
|
/// construct the client with the correct username before calling this.
|
||||||
|
pub async fn login(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/login", self.base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": self.username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Self::extract_auth_tokens(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new account with a username + password and return `(access_token, refresh_token)`.
|
||||||
|
///
|
||||||
|
/// On success call [`crate::auth_tokens::store_tokens`] with the returned pair.
|
||||||
|
pub async fn register(&self, password: &str) -> Result<(String, String), SyncError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/auth/register", self.base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": self.username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Self::extract_auth_tokens(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `{ "access_token": "...", "refresh_token": "..." }` from an auth response.
|
||||||
|
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
let msg = body["error"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| body["message"].as_str())
|
||||||
|
.unwrap_or("authentication failed");
|
||||||
|
return Err(if status == reqwest::StatusCode::CONFLICT {
|
||||||
|
SyncError::Auth("username already taken".into())
|
||||||
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
SyncError::Auth("invalid credentials".into())
|
||||||
|
} else if status == reqwest::StatusCode::BAD_REQUEST {
|
||||||
|
SyncError::Auth(msg.to_string())
|
||||||
|
} else {
|
||||||
|
SyncError::Network(format!("server returned {status}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Serialization(e.to_string()))?;
|
||||||
|
let access = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SyncError::Serialization("missing access_token".into()))?
|
||||||
|
.to_string();
|
||||||
|
let refresh = body["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| SyncError::Serialization("missing refresh_token".into()))?
|
||||||
|
.to_string();
|
||||||
|
Ok((access, refresh))
|
||||||
|
}
|
||||||
|
|
||||||
/// Attempt to refresh the access token using the stored refresh token.
|
/// Attempt to refresh the access token using the stored refresh token.
|
||||||
///
|
///
|
||||||
/// On success the new access token is persisted to the OS keychain,
|
/// The server rotates refresh tokens on each call: the response includes a
|
||||||
/// replacing the previous one. The refresh token itself is unchanged.
|
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||||
|
/// to the OS keychain on success.
|
||||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||||
let refresh = load_refresh_token(&self.username)
|
let old_refresh = load_refresh_token(&self.username)
|
||||||
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
.map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/api/auth/refresh", self.base_url))
|
.post(format!("{}/api/auth/refresh", self.base_url))
|
||||||
.json(&serde_json::json!({ "refresh_token": refresh }))
|
.json(&serde_json::json!({ "refresh_token": old_refresh }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
@@ -112,9 +190,11 @@ impl SolitaireServerClient {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
|
||||||
|
|
||||||
// store_tokens replaces both access and refresh; we keep the old
|
// Server rotates refresh tokens — store the new one.
|
||||||
// refresh token unchanged so its 30-day TTL is preserved.
|
// Fall back to the old token if the field is absent (pre-rotation server).
|
||||||
store_tokens(&self.username, new_access, &refresh)
|
let new_refresh = body["refresh_token"].as_str().unwrap_or(&old_refresh);
|
||||||
|
|
||||||
|
store_tokens(&self.username, new_access, new_refresh)
|
||||||
.map_err(|e| SyncError::Auth(e.to_string()))
|
.map_err(|e| SyncError::Auth(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ pub struct AchievementUnlockedEvent(pub AchievementRecord);
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ManualSyncRequestEvent;
|
pub struct ManualSyncRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the sync-server setup modal (Connect flow).
|
||||||
|
/// Fired by the "Connect" button in the Settings sync section.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SyncConfigureRequestEvent;
|
||||||
|
|
||||||
|
/// Request to disconnect from the current sync backend, clear stored
|
||||||
|
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||||
|
/// button in the Settings sync section.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SyncLogoutRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the account-deletion confirmation modal. Fired by the
|
||||||
|
/// "Delete Account" button in the Settings sync section (visible only when
|
||||||
|
/// a server backend is configured). Consumed by `SyncSetupPlugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DeleteAccountRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
||||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub mod selection_plugin;
|
|||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
pub mod sync_plugin;
|
pub mod sync_plugin;
|
||||||
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
@@ -150,6 +151,7 @@ pub use stats_plugin::{
|
|||||||
StatsScreen, StatsUpdate, WatchReplayButton,
|
StatsScreen, StatsUpdate, WatchReplayButton,
|
||||||
};
|
};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
|
pub use sync_setup_plugin::SyncSetupPlugin;
|
||||||
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||||
pub use ui_modal::{
|
pub use ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ use solitaire_data::{
|
|||||||
TOOLTIP_DELAY_STEP_SECS,
|
TOOLTIP_DELAY_STEP_SECS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
use solitaire_data::settings::SyncBackend;
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
@@ -231,6 +236,12 @@ enum SettingsButton {
|
|||||||
/// player's last window size always wins.
|
/// player's last window size always wins.
|
||||||
ToggleSmartDefaultSize,
|
ToggleSmartDefaultSize,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
|
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||||
|
ConnectSync,
|
||||||
|
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||||
|
DisconnectSync,
|
||||||
|
/// Open the account-deletion confirmation modal.
|
||||||
|
DeleteAccount,
|
||||||
Done,
|
Done,
|
||||||
/// Select a specific card-back by index from the picker row.
|
/// Select a specific card-back by index from the picker row.
|
||||||
SelectCardBack(usize),
|
SelectCardBack(usize),
|
||||||
@@ -284,6 +295,9 @@ impl SettingsButton {
|
|||||||
SettingsButton::SelectTheme(_) => 85,
|
SettingsButton::SelectTheme(_) => 85,
|
||||||
// Sync section
|
// Sync section
|
||||||
SettingsButton::SyncNow => 90,
|
SettingsButton::SyncNow => 90,
|
||||||
|
SettingsButton::ConnectSync => 91,
|
||||||
|
SettingsButton::DisconnectSync => 92,
|
||||||
|
SettingsButton::DeleteAccount => 93,
|
||||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||||
// value here is only a fallback for completeness.
|
// value here is only a fallback for completeness.
|
||||||
@@ -333,6 +347,9 @@ impl Plugin for SettingsPlugin {
|
|||||||
.init_resource::<PendingWindowGeometry>()
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||||
@@ -359,6 +376,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
(
|
(
|
||||||
sync_settings_panel_visibility,
|
sync_settings_panel_visibility,
|
||||||
handle_settings_buttons,
|
handle_settings_buttons,
|
||||||
|
handle_sync_buttons,
|
||||||
update_sync_status_text,
|
update_sync_status_text,
|
||||||
update_card_back_text,
|
update_card_back_text,
|
||||||
update_background_text,
|
update_background_text,
|
||||||
@@ -840,7 +858,6 @@ fn handle_settings_buttons(
|
|||||||
mut screen: ResMut<SettingsScreen>,
|
mut screen: ResMut<SettingsScreen>,
|
||||||
path: Res<SettingsStoragePath>,
|
path: Res<SettingsStoragePath>,
|
||||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
|
||||||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
|
||||||
@@ -1053,8 +1070,11 @@ fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsButton::SyncNow => {
|
SettingsButton::SyncNow
|
||||||
manual_sync.write(ManualSyncRequestEvent);
|
| SettingsButton::ConnectSync
|
||||||
|
| SettingsButton::DisconnectSync
|
||||||
|
| SettingsButton::DeleteAccount => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
}
|
}
|
||||||
SettingsButton::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
@@ -1063,6 +1083,30 @@ fn handle_settings_buttons(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
|
/// Bevy's 16-parameter system limit.
|
||||||
|
fn handle_sync_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
||||||
|
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
|
||||||
|
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
||||||
|
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_mode_label(mode: &DrawMode) -> String {
|
fn draw_mode_label(mode: &DrawMode) -> String {
|
||||||
match mode {
|
match mode {
|
||||||
DrawMode::DrawOne => "Draw 1".into(),
|
DrawMode::DrawOne => "Draw 1".into(),
|
||||||
@@ -1596,7 +1640,7 @@ fn spawn_settings_panel(
|
|||||||
|
|
||||||
// --- Sync ---
|
// --- Sync ---
|
||||||
section_label(body, "Sync", font_res);
|
section_label(body, "Sync", font_res);
|
||||||
sync_row(body, sync_status, font_res);
|
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Done is the only action — primary so the player always knows
|
// Done is the only action — primary so the player always knows
|
||||||
@@ -2208,8 +2252,14 @@ fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status text + manual "Sync Now" button.
|
/// Sync section row — shows different controls depending on whether a server
|
||||||
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
/// backend is configured.
|
||||||
|
fn sync_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
status_text: &str,
|
||||||
|
backend: &SyncBackend,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
let status_font = TextFont {
|
let status_font = TextFont {
|
||||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -2220,45 +2270,105 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper closure to spawn a small settings-style pill button.
|
||||||
|
let small_button = |row: &mut ChildSpawnerCommands,
|
||||||
|
marker: SettingsButton,
|
||||||
|
label: &str,
|
||||||
|
tooltip: String,
|
||||||
|
font: TextFont| {
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
font,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Column,
|
||||||
align_items: AlignItems::Center,
|
row_gap: VAL_SPACE_2,
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|col| {
|
||||||
row.spawn((
|
// Status line + inline action buttons.
|
||||||
SyncStatusText,
|
col.spawn(Node {
|
||||||
Text::new(status_text.to_string()),
|
flex_direction: FlexDirection::Row,
|
||||||
status_font,
|
align_items: AlignItems::Center,
|
||||||
TextColor(TEXT_SECONDARY),
|
column_gap: VAL_SPACE_3,
|
||||||
));
|
flex_wrap: FlexWrap::Wrap,
|
||||||
// ManualSyncRequestEvent is always registered, so this
|
row_gap: VAL_SPACE_2,
|
||||||
// button is safe to show even when SyncPlugin is absent.
|
..default()
|
||||||
row.spawn((
|
})
|
||||||
SettingsButton::SyncNow,
|
.with_children(|row| {
|
||||||
Button,
|
row.spawn((
|
||||||
Tooltip::new(
|
SyncStatusText,
|
||||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
Text::new(status_text.to_string()),
|
||||||
),
|
status_font,
|
||||||
Node {
|
TextColor(TEXT_SECONDARY),
|
||||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(BG_ELEVATED_HI),
|
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Sync Now"),
|
|
||||||
button_font,
|
|
||||||
TextColor(TEXT_PRIMARY),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
match backend {
|
||||||
|
SyncBackend::Local => {
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::ConnectSync,
|
||||||
|
"Connect",
|
||||||
|
"Connect to a self-hosted Solitaire Quest sync server.".to_string(),
|
||||||
|
button_font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SyncBackend::SolitaireServer { username, .. } => {
|
||||||
|
// Show the logged-in username as a secondary label.
|
||||||
|
row.spawn((
|
||||||
|
Text::new(format!("({username})")),
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::SyncNow,
|
||||||
|
"Sync Now",
|
||||||
|
"Push and pull stats now. Runs automatically on launch and exit.".to_string(),
|
||||||
|
button_font.clone(),
|
||||||
|
);
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::DisconnectSync,
|
||||||
|
"Disconnect",
|
||||||
|
"Unlink this device from the sync server.".to_string(),
|
||||||
|
button_font.clone(),
|
||||||
|
);
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::DeleteAccount,
|
||||||
|
"Delete Account",
|
||||||
|
"Permanently delete your account and all server data. Cannot be undone.".to_string(),
|
||||||
|
button_font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2620,19 +2730,20 @@ mod tests {
|
|||||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spot-check: the Sync Now button's tooltip text is the
|
// Spot-check: with default (Local) settings the Connect button
|
||||||
// canonical microcopy. We find it via the `SettingsButton`
|
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||||
// discriminant — there is exactly one Sync Now entity per panel.
|
let connect_tip = app
|
||||||
let sync_tip = app
|
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
|
.find_map(|(btn, tip)| {
|
||||||
.expect("Sync Now button should spawn with a Tooltip");
|
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
||||||
|
})
|
||||||
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sync_tip.as_ref(),
|
connect_tip.as_ref(),
|
||||||
"Push and pull stats now. Runs automatically on launch and exit.",
|
"Connect to a self-hosted Solitaire Quest sync server.",
|
||||||
"Sync Now tooltip must use the canonical microcopy"
|
"ConnectSync tooltip must use the canonical microcopy"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ use solitaire_data::{
|
|||||||
use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent};
|
use crate::events::{
|
||||||
|
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
||||||
|
SyncConfigureRequestEvent,
|
||||||
|
};
|
||||||
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};
|
||||||
@@ -104,6 +107,8 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<PendingReplayUpload>()
|
.init_resource::<PendingReplayUpload>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -191,6 +196,8 @@ fn poll_pull_result(
|
|||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else {
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -240,10 +247,19 @@ fn poll_pull_result(
|
|||||||
warn!("sync pull failed: {e}");
|
warn!("sync pull failed: {e}");
|
||||||
let msg = match &e {
|
let msg = match &e {
|
||||||
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
|
SyncError::Network(_) => "Can't reach server — check your connection".to_string(),
|
||||||
SyncError::Auth(_) => "Login expired — tap Sync Now after re-logging in".to_string(),
|
SyncError::Auth(_) => "Session expired — please reconnect".to_string(),
|
||||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
|
// On auth failure, reopen the Connect modal so the player can
|
||||||
|
// re-enter credentials without having to navigate through Settings.
|
||||||
|
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||||
|
// the modal is already on screen, so repeated pull failures don't
|
||||||
|
// stack multiple modals.
|
||||||
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
|
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
status.0 = SyncStatus::Error(msg.clone());
|
status.0 = SyncStatus::Error(msg.clone());
|
||||||
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,876 @@
|
|||||||
|
//! Sync-server configuration UI: login / register modal, provider hot-swap,
|
||||||
|
//! and disconnect handler.
|
||||||
|
//!
|
||||||
|
//! # Flow (connect)
|
||||||
|
//!
|
||||||
|
//! 1. Player clicks "Connect" in the Settings sync section.
|
||||||
|
//! 2. `SyncConfigureRequestEvent` → `open_sync_setup_modal` spawns the form.
|
||||||
|
//! 3. Player fills URL / Username / Password; Tab cycles fields.
|
||||||
|
//! 4. "Log In" or "Register" → `handle_auth_button` → async task on
|
||||||
|
//! `AsyncComputeTaskPool` calling `SolitaireServerClient::login` or
|
||||||
|
//! `::register`.
|
||||||
|
//! 5. `poll_auth_task` harvests the result:
|
||||||
|
//! - **Ok**: store tokens → update `SettingsResource` → swap
|
||||||
|
//! `SyncProviderResource` → fire `ManualSyncRequestEvent` → toast + close.
|
||||||
|
//! - **Err**: display error inline; form stays open.
|
||||||
|
//!
|
||||||
|
//! # Flow (disconnect)
|
||||||
|
//!
|
||||||
|
//! `SyncLogoutRequestEvent` → `handle_logout` clears tokens, resets
|
||||||
|
//! `SyncBackend::Local`, swaps provider, closes settings, shows toast.
|
||||||
|
//!
|
||||||
|
//! # Flow (delete account)
|
||||||
|
//!
|
||||||
|
//! 1. Player clicks "Delete Account" in Settings.
|
||||||
|
//! 2. `DeleteAccountRequestEvent` → `open_delete_confirm_modal` spawns a
|
||||||
|
//! two-button confirmation modal.
|
||||||
|
//! 3. "Cancel" → despawn modal.
|
||||||
|
//! 4. "Delete Forever" → `handle_delete_confirm` → async task on
|
||||||
|
//! `AsyncComputeTaskPool` calling `SyncProvider::delete_account`.
|
||||||
|
//! 5. `poll_delete_task` harvests the result:
|
||||||
|
//! - **Ok**: fire `SyncLogoutRequestEvent` (clears tokens + resets backend)
|
||||||
|
//! + toast.
|
||||||
|
//! - **Err**: display error in a toast; modal is already closed.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bevy::input::ButtonState;
|
||||||
|
use bevy::input::keyboard::KeyboardInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
use solitaire_data::{
|
||||||
|
auth_tokens::{delete_tokens, store_tokens},
|
||||||
|
settings::SyncBackend,
|
||||||
|
save_settings_to,
|
||||||
|
sync_client::{LocalOnlyProvider, SolitaireServerClient},
|
||||||
|
SyncError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||||
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
|
use crate::ui_modal::spawn_modal;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
|
||||||
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the sync-setup modal scrim (despawn root).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct SyncSetupScreen;
|
||||||
|
|
||||||
|
/// Discriminant attached to each input-field container and inner text entity.
|
||||||
|
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum SyncFieldKind {
|
||||||
|
Url,
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-field raw-text buffer, stored on the inner text entity.
|
||||||
|
#[derive(Component, Default, Debug)]
|
||||||
|
struct SyncFieldBuffer(String);
|
||||||
|
|
||||||
|
/// Marker on the error-message text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncAuthError;
|
||||||
|
|
||||||
|
/// Marks the "Log In" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncLoginButton;
|
||||||
|
|
||||||
|
/// Marks the "Register" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncRegisterButton;
|
||||||
|
|
||||||
|
/// Marks the "Cancel" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncCancelButton;
|
||||||
|
|
||||||
|
/// Marks the spinner / busy overlay node shown while the auth task is running.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncBusyOverlay;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Which field in the sync-setup modal currently has keyboard focus.
|
||||||
|
#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum SyncFocusedField {
|
||||||
|
#[default]
|
||||||
|
Url,
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncFocusedField {
|
||||||
|
fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Url => Self::Username,
|
||||||
|
Self::Username => Self::Password,
|
||||||
|
Self::Password => Self::Url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(self) -> SyncFieldKind {
|
||||||
|
match self {
|
||||||
|
Self::Url => SyncFieldKind::Url,
|
||||||
|
Self::Username => SyncFieldKind::Username,
|
||||||
|
Self::Password => SyncFieldKind::Password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight login/register task. `url` and `username` are preserved so the
|
||||||
|
/// poll system can update settings and provider on success without re-reading
|
||||||
|
/// the (already-despawned or cleared) form fields.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PendingAuthTask {
|
||||||
|
task: Option<Task<Result<(String, String), SyncError>>>,
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the account-deletion confirmation modal root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DeleteConfirmScreen;
|
||||||
|
|
||||||
|
/// Marks the "Delete Forever" confirmation button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DeleteConfirmButton;
|
||||||
|
|
||||||
|
/// Marks the cancel button inside the delete-confirm modal.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DeleteCancelButton;
|
||||||
|
|
||||||
|
/// In-flight account-deletion task.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PendingDeleteTask(Option<Task<Result<(), SyncError>>>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers the sync configuration UI systems and resources.
|
||||||
|
pub struct SyncSetupPlugin;
|
||||||
|
|
||||||
|
impl Plugin for SyncSetupPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<SyncFocusedField>()
|
||||||
|
.init_resource::<PendingAuthTask>()
|
||||||
|
.init_resource::<PendingDeleteTask>()
|
||||||
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
open_sync_setup_modal,
|
||||||
|
handle_text_input,
|
||||||
|
update_field_borders,
|
||||||
|
handle_auth_button,
|
||||||
|
poll_auth_task,
|
||||||
|
handle_cancel,
|
||||||
|
handle_logout,
|
||||||
|
open_delete_confirm_modal,
|
||||||
|
handle_delete_cancel,
|
||||||
|
handle_delete_confirm,
|
||||||
|
poll_delete_task,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
||||||
|
fn open_sync_setup_modal(
|
||||||
|
mut events: MessageReader<SyncConfigureRequestEvent>,
|
||||||
|
existing: Query<(), With<SyncSetupScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut focused: ResMut<SyncFocusedField>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return; // Already open.
|
||||||
|
}
|
||||||
|
*focused = SyncFocusedField::Url;
|
||||||
|
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes keyboard input to the focused field while the modal is open.
|
||||||
|
fn handle_text_input(
|
||||||
|
screen: Query<(), With<SyncSetupScreen>>,
|
||||||
|
mut key_events: MessageReader<KeyboardInput>,
|
||||||
|
mut focused: ResMut<SyncFocusedField>,
|
||||||
|
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer, &mut Text, &mut TextColor)>,
|
||||||
|
pending: Res<PendingAuthTask>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() || pending.task.is_some() {
|
||||||
|
// Swallow events while modal is closed or auth is in flight.
|
||||||
|
key_events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ev in key_events.read() {
|
||||||
|
if ev.state != ButtonState::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab / Shift-Tab cycle focus.
|
||||||
|
if ev.key_code == KeyCode::Tab {
|
||||||
|
let shift = ev.logical_key == bevy::input::keyboard::Key::Tab; // no-shift
|
||||||
|
let _ = shift; // handled below via modifier check
|
||||||
|
// Bevy doesn't give us the shift modifier state on KeyboardInput directly,
|
||||||
|
// so we check key_code == Tab and trust that shift produces a separate event.
|
||||||
|
// Use ButtonInput<KeyCode> alternative: we check Tab key here and rely on
|
||||||
|
// the SyncFocusedField cycling being called per press.
|
||||||
|
*focused = focused.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.key_code == KeyCode::Backspace {
|
||||||
|
for (kind, mut buf, mut text, _) in &mut fields {
|
||||||
|
if *kind == focused.kind() {
|
||||||
|
buf.0.pop();
|
||||||
|
text.0 = display_text(&buf.0, *kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printable character — append to focused buffer.
|
||||||
|
if let Some(ch) = ev.text.as_deref().and_then(printable_char) {
|
||||||
|
for (kind, mut buf, mut text, mut color) in &mut fields {
|
||||||
|
if *kind == focused.kind() {
|
||||||
|
if buf.0.len() < 256 {
|
||||||
|
buf.0.push(ch);
|
||||||
|
}
|
||||||
|
text.0 = display_text(&buf.0, *kind);
|
||||||
|
color.0 = TEXT_PRIMARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the border colour of each input field based on which field is focused.
|
||||||
|
fn update_field_borders(
|
||||||
|
screen: Query<(), With<SyncSetupScreen>>,
|
||||||
|
focused: Res<SyncFocusedField>,
|
||||||
|
mut borders: Query<(&SyncFieldKind, &mut BorderColor), Without<SyncFieldBuffer>>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() || !focused.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (kind, mut border) in &mut borders {
|
||||||
|
*border = BorderColor::all(if *kind == focused.kind() {
|
||||||
|
ACCENT_PRIMARY
|
||||||
|
} else {
|
||||||
|
BORDER_SUBTLE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires an async auth task when Login or Register is clicked.
|
||||||
|
fn handle_auth_button(
|
||||||
|
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||||
|
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||||
|
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||||
|
mut pending: ResMut<PendingAuthTask>,
|
||||||
|
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||||
|
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||||
|
) {
|
||||||
|
let login_clicked = login_q
|
||||||
|
.iter()
|
||||||
|
.any(|i| *i == Interaction::Pressed);
|
||||||
|
let register_clicked = register_q
|
||||||
|
.iter()
|
||||||
|
.any(|i| *i == Interaction::Pressed);
|
||||||
|
|
||||||
|
if !login_clicked && !register_clicked {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if pending.task.is_some() {
|
||||||
|
return; // Already in flight.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect field values.
|
||||||
|
let mut url = String::new();
|
||||||
|
let mut username = String::new();
|
||||||
|
let mut password = String::new();
|
||||||
|
for (kind, buf) in &fields {
|
||||||
|
match kind {
|
||||||
|
SyncFieldKind::Url => url = buf.0.trim().to_string(),
|
||||||
|
SyncFieldKind::Username => username = buf.0.trim().to_string(),
|
||||||
|
SyncFieldKind::Password => password = buf.0.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation before hitting the network.
|
||||||
|
let validation_error = if url.is_empty() {
|
||||||
|
Some("Server URL is required")
|
||||||
|
} else if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
Some("URL must start with http:// or https://")
|
||||||
|
} else if username.is_empty() {
|
||||||
|
Some("Username is required")
|
||||||
|
} else if password.is_empty() {
|
||||||
|
Some("Password is required")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(msg) = validation_error {
|
||||||
|
for (mut text, mut color) in &mut error_nodes {
|
||||||
|
text.0 = msg.to_string();
|
||||||
|
color.0 = STATE_DANGER;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error and show busy indicator.
|
||||||
|
for (mut text, _) in &mut error_nodes {
|
||||||
|
text.0 = "Connecting…".to_string();
|
||||||
|
}
|
||||||
|
for mut vis in &mut busy_nodes {
|
||||||
|
*vis = Visibility::Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_register = register_clicked;
|
||||||
|
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
||||||
|
let pw = password.clone();
|
||||||
|
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||||
|
.block_on(async {
|
||||||
|
if is_register {
|
||||||
|
client.register(&pw).await
|
||||||
|
} else {
|
||||||
|
client.login(&pw).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
pending.task = Some(task);
|
||||||
|
pending.url = url;
|
||||||
|
pending.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the in-flight auth task. On success updates settings + provider.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn poll_auth_task(
|
||||||
|
mut pending: ResMut<PendingAuthTask>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
settings_path: Res<SettingsStoragePath>,
|
||||||
|
mut provider: ResMut<SyncProviderResource>,
|
||||||
|
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||||
|
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||||
|
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||||
|
mut settings_screen: ResMut<SettingsScreen>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(task) = pending.task.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.task = None;
|
||||||
|
|
||||||
|
for mut vis in &mut busy_nodes {
|
||||||
|
*vis = Visibility::Hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((access_token, refresh_token)) => {
|
||||||
|
let url = pending.url.clone();
|
||||||
|
let username = pending.username.clone();
|
||||||
|
|
||||||
|
// Persist tokens to the OS keychain / Android Keystore.
|
||||||
|
if let Err(e) = store_tokens(&username, &access_token, &refresh_token) {
|
||||||
|
for (mut text, mut color) in &mut error_nodes {
|
||||||
|
text.0 = format!("Token storage failed: {e}");
|
||||||
|
color.0 = STATE_DANGER;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings and persist.
|
||||||
|
settings.0.sync_backend = SyncBackend::SolitaireServer {
|
||||||
|
url: url.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
};
|
||||||
|
if let Some(path) = &settings_path.0
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("sync setup: failed to persist settings: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot-swap the provider so pull/push use the new credentials.
|
||||||
|
provider.0 = Arc::new(SolitaireServerClient::new(url, username.clone()));
|
||||||
|
|
||||||
|
// Kick off an immediate pull with the new provider.
|
||||||
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
|
|
||||||
|
// Close both the setup modal and the settings panel.
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
settings_screen.0 = false;
|
||||||
|
|
||||||
|
toast.write(InfoToastEvent(format!("Connected as {username}")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = match e {
|
||||||
|
SyncError::Auth(m) => m,
|
||||||
|
SyncError::Network(m) => format!("Network error: {m}"),
|
||||||
|
SyncError::Serialization(m) => format!("Unexpected response: {m}"),
|
||||||
|
SyncError::UnsupportedPlatform => "Unsupported platform".into(),
|
||||||
|
};
|
||||||
|
for (mut text, mut color) in &mut error_nodes {
|
||||||
|
text.0 = msg.clone();
|
||||||
|
color.0 = STATE_DANGER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the sync-setup modal on Cancel click or Escape.
|
||||||
|
fn handle_cancel(
|
||||||
|
cancel_q: Query<&Interaction, (Changed<Interaction>, With<SyncCancelButton>)>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screen: Query<Entity, With<SyncSetupScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|
||||||
|
|| keys.just_pressed(KeyCode::Escape);
|
||||||
|
if !cancelled || screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears stored tokens, resets the backend to `Local`, and hot-swaps the
|
||||||
|
/// provider. Triggered by "Disconnect" in the settings sync section.
|
||||||
|
fn handle_logout(
|
||||||
|
mut events: MessageReader<SyncLogoutRequestEvent>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
settings_path: Res<SettingsStoragePath>,
|
||||||
|
mut provider: ResMut<SyncProviderResource>,
|
||||||
|
mut settings_screen: ResMut<SettingsScreen>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// Extract username before resetting so we can clear the right keychain key.
|
||||||
|
let username = match &settings.0.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
|
||||||
|
SyncBackend::Local => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(u) = username
|
||||||
|
&& let Err(e) = delete_tokens(&u)
|
||||||
|
{
|
||||||
|
warn!("sync logout: failed to clear tokens: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.0.sync_backend = SyncBackend::Local;
|
||||||
|
if let Some(path) = &settings_path.0
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("sync logout: failed to persist settings: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.0 = Arc::new(LocalOnlyProvider);
|
||||||
|
settings_screen.0 = false;
|
||||||
|
toast.write(InfoToastEvent("Disconnected from sync server".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the account-deletion confirmation modal when `DeleteAccountRequestEvent` fires.
|
||||||
|
fn open_delete_confirm_modal(
|
||||||
|
mut events: MessageReader<DeleteAccountRequestEvent>,
|
||||||
|
existing: Query<(), With<DeleteConfirmScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the delete-confirm modal on the cancel button or Escape.
|
||||||
|
fn handle_delete_cancel(
|
||||||
|
cancel_q: Query<&Interaction, (Changed<Interaction>, With<DeleteCancelButton>)>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let cancelled = cancel_q.iter().any(|i| *i == Interaction::Pressed)
|
||||||
|
|| keys.just_pressed(KeyCode::Escape);
|
||||||
|
if !cancelled || screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the async delete-account task when "Delete Forever" is clicked.
|
||||||
|
fn handle_delete_confirm(
|
||||||
|
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
||||||
|
provider: Res<SyncProviderResource>,
|
||||||
|
mut pending: ResMut<PendingDeleteTask>,
|
||||||
|
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !confirm_q.iter().any(|i| *i == Interaction::Pressed) || pending.0.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Despawn the confirmation modal immediately so the player can't double-click.
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
let provider = provider.0.clone();
|
||||||
|
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||||
|
.block_on(provider.delete_account())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the in-flight delete-account task. On success fires `SyncLogoutRequestEvent`.
|
||||||
|
fn poll_delete_task(
|
||||||
|
mut pending: ResMut<PendingDeleteTask>,
|
||||||
|
mut logout: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
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(()) => {
|
||||||
|
logout.write(SyncLogoutRequestEvent);
|
||||||
|
toast.write(InfoToastEvent("Account deleted".to_string()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = match e {
|
||||||
|
SyncError::Auth(_) => "Not authorised — try reconnecting first".to_string(),
|
||||||
|
SyncError::Network(m) => format!("Network error: {m}"),
|
||||||
|
other => format!("Delete failed: {other}"),
|
||||||
|
};
|
||||||
|
toast.write(InfoToastEvent(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_modal(commands, SyncSetupScreen, Z_MODAL_PANEL + 1, |card| {
|
||||||
|
// Header.
|
||||||
|
card.spawn(Node {
|
||||||
|
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|h| {
|
||||||
|
h.spawn((
|
||||||
|
Text::new("Connect to Server"),
|
||||||
|
make_font(font_res, TYPE_BODY_LG),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scrollable body — three labeled input fields + error line.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_3,
|
||||||
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||||
|
flex_grow: 1.0,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|body| {
|
||||||
|
spawn_field(
|
||||||
|
body,
|
||||||
|
SyncFieldKind::Url,
|
||||||
|
"Server URL",
|
||||||
|
"https://your-server.example.com",
|
||||||
|
true, // focused initially
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_field(
|
||||||
|
body,
|
||||||
|
SyncFieldKind::Username,
|
||||||
|
"Username",
|
||||||
|
"your-username",
|
||||||
|
false,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
spawn_field(
|
||||||
|
body,
|
||||||
|
SyncFieldKind::Password,
|
||||||
|
"Password",
|
||||||
|
"••••••••",
|
||||||
|
false,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error / status line.
|
||||||
|
body.spawn(Node {
|
||||||
|
min_height: Val::Px(18.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn((
|
||||||
|
SyncAuthError,
|
||||||
|
SyncBusyOverlay,
|
||||||
|
Text::new(String::new()),
|
||||||
|
make_font(font_res, TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
Visibility::Hidden,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab hint.
|
||||||
|
body.spawn((
|
||||||
|
Text::new("Tab = next field"),
|
||||||
|
make_font(font_res, TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action row.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|actions| {
|
||||||
|
spawn_action_button(actions, SyncCancelButton, "Cancel", false, font_res);
|
||||||
|
spawn_action_button(actions, SyncRegisterButton, "Register", false, font_res);
|
||||||
|
spawn_action_button(actions, SyncLoginButton, "Log In", true, font_res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_field(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
kind: SyncFieldKind,
|
||||||
|
label: &str,
|
||||||
|
placeholder: &str,
|
||||||
|
focused: bool,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: Val::Px(4.0),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
// Label.
|
||||||
|
col.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
make_font(font_res, TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Input border container — carries kind for the border-update system.
|
||||||
|
col.spawn((
|
||||||
|
kind,
|
||||||
|
Node {
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_2, Val::Px(6.0)),
|
||||||
|
min_height: Val::Px(32.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(if focused { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|border| {
|
||||||
|
// Inner text / buffer entity.
|
||||||
|
border.spawn((
|
||||||
|
kind,
|
||||||
|
SyncFieldBuffer(String::new()),
|
||||||
|
Text::new(placeholder.to_string()),
|
||||||
|
make_font(font_res, TYPE_BODY),
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_action_button<M: Component>(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
primary: bool,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let bg = if primary { ACCENT_PRIMARY } else { BG_ELEVATED_HI };
|
||||||
|
let fg = TEXT_PRIMARY;
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(bg),
|
||||||
|
BorderColor::all(if primary { ACCENT_PRIMARY } else { BORDER_SUBTLE }),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
make_font(font_res, TYPE_BODY),
|
||||||
|
TextColor(fg),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_font(font_res: Option<&FontResource>, size: f32) -> TextFont {
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: size,
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_delete_confirm_modal(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_modal(commands, DeleteConfirmScreen, Z_MODAL_PANEL + 2, |card| {
|
||||||
|
// Header.
|
||||||
|
card.spawn(Node {
|
||||||
|
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|h| {
|
||||||
|
h.spawn((
|
||||||
|
Text::new("Delete Account"),
|
||||||
|
make_font(font_res, TYPE_BODY_LG),
|
||||||
|
TextColor(STATE_DANGER),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|body| {
|
||||||
|
body.spawn((
|
||||||
|
Text::new(
|
||||||
|
"This permanently deletes your account and all server data.\n\
|
||||||
|
Local progress is kept. This cannot be undone.",
|
||||||
|
),
|
||||||
|
make_font(font_res, TYPE_BODY),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::new(VAL_SPACE_4, VAL_SPACE_4, VAL_SPACE_2, VAL_SPACE_3),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|actions| {
|
||||||
|
spawn_action_button(actions, DeleteCancelButton, "Cancel", false, font_res);
|
||||||
|
// "Delete Forever" button — danger styling (STATE_DANGER background).
|
||||||
|
actions
|
||||||
|
.spawn((
|
||||||
|
DeleteConfirmButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(STATE_DANGER),
|
||||||
|
BorderColor::all(STATE_DANGER),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Delete Forever"),
|
||||||
|
make_font(font_res, TYPE_BODY),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the display string for a field — password fields show bullets.
|
||||||
|
fn display_text(raw: &str, kind: SyncFieldKind) -> String {
|
||||||
|
if kind == SyncFieldKind::Password {
|
||||||
|
"•".repeat(raw.len())
|
||||||
|
} else {
|
||||||
|
raw.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a printable ASCII character from a SmolStr keypress text.
|
||||||
|
fn printable_char(text: &str) -> Option<char> {
|
||||||
|
let ch = text.chars().next()?;
|
||||||
|
// Accept printable ASCII: 0x20 (space) through 0x7e (~).
|
||||||
|
(' '..='~').contains(&ch).then_some(ch)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
# The server reads these on startup via dotenvy.
|
||||||
|
|
||||||
|
# SQLite database path. For local dev use a file path; for Docker use the
|
||||||
|
# volume-mounted path (see docker-compose.yml).
|
||||||
|
DATABASE_URL=sqlite://sol.db
|
||||||
|
|
||||||
|
# HS256 signing secret for JWT tokens. Use at least 32 random characters.
|
||||||
|
# Generate one with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=change-me-use-openssl-rand-hex-32
|
||||||
|
|
||||||
|
# TCP port to listen on (optional, default 8080).
|
||||||
|
# SERVER_PORT=8080
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# --- Build stage ---
|
||||||
|
FROM rust:1.95-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install musl tools for a fully static binary and sqlx-cli for compile-time
|
||||||
|
# query checking (SQLX_OFFLINE=true skips the live-DB check at build time).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy only the files needed to build the server crate.
|
||||||
|
# Layer order: workspace manifests first so dependency fetches are cached.
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||||
|
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||||
|
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||||
|
|
||||||
|
# Stub every crate source so `cargo fetch` succeeds without full source.
|
||||||
|
RUN mkdir -p solitaire_sync/src solitaire_server/src solitaire_core/src && \
|
||||||
|
echo "pub fn _stub() {}" > solitaire_sync/src/lib.rs && \
|
||||||
|
echo "pub fn _stub() {}" > solitaire_core/src/lib.rs && \
|
||||||
|
echo "pub fn _stub() {}" > solitaire_server/src/lib.rs && \
|
||||||
|
echo "fn main() {}" > solitaire_server/src/main.rs
|
||||||
|
|
||||||
|
RUN cargo fetch --locked
|
||||||
|
|
||||||
|
# Now copy real source and build in release mode.
|
||||||
|
COPY solitaire_core/src ./solitaire_core/src
|
||||||
|
COPY solitaire_sync/src ./solitaire_sync/src
|
||||||
|
COPY solitaire_server/src ./solitaire_server/src
|
||||||
|
COPY solitaire_server/migrations ./solitaire_server/migrations
|
||||||
|
# sqlx offline query cache — required when SQLX_OFFLINE=true so the
|
||||||
|
# compile-time macros don't need a live database.
|
||||||
|
COPY .sqlx ./.sqlx
|
||||||
|
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
|
||||||
|
|
||||||
|
# --- Runtime stage ---
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/solitaire_server ./solitaire_server
|
||||||
|
# Migrations are embedded via sqlx::migrate!("./migrations") relative to the
|
||||||
|
# crate root at compile time — they do not need to be copied here.
|
||||||
|
|
||||||
|
ENV SERVER_PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["./solitaire_server"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: solitaire_server/Dockerfile
|
||||||
|
image: solitaire-quest-server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
# SQLite database persisted outside the container.
|
||||||
|
- db-data:/app/data
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: sqlite:///app/data/sol.db
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SERVER_PORT: 8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration 003: refresh token rotation table
|
||||||
|
--
|
||||||
|
-- One row per live refresh token. Issued at login/register and rotated
|
||||||
|
-- (old row deleted, new row inserted) on every POST /api/auth/refresh call.
|
||||||
|
-- Cascade on user deletion means no manual cleanup is needed when an
|
||||||
|
-- account is removed.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
jti TEXT PRIMARY KEY, -- UUID v4 embedded in the JWT
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL -- ISO 8601, mirrors the JWT exp claim
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Expired-row pruning (done inline in the refresh handler) uses this index
|
||||||
|
-- to avoid a full table scan on every refresh call.
|
||||||
|
CREATE INDEX IF NOT EXISTS refresh_tokens_expires_at_idx
|
||||||
|
ON refresh_tokens(expires_at);
|
||||||
+112
-20
@@ -37,10 +37,13 @@ pub struct AuthResponse {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful refresh response — contains only the new access token.
|
/// Successful refresh response — contains the new access token and the rotated
|
||||||
|
/// refresh token. The refresh token is always rotated: the client must store
|
||||||
|
/// the new value and discard the old one.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct RefreshResponse {
|
pub struct RefreshResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,21 +76,47 @@ pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError
|
|||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
exp,
|
exp,
|
||||||
kind: "access".to_string(),
|
kind: "access".to_string(),
|
||||||
|
jti: None,
|
||||||
};
|
};
|
||||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))
|
.map_err(|e| AppError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
||||||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
///
|
||||||
|
/// Returns `(jwt_string, jti)`. The caller must insert the jti into
|
||||||
|
/// `refresh_tokens` before returning the JWT to the client.
|
||||||
|
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String), AppError> {
|
||||||
|
let jti = Uuid::new_v4().to_string();
|
||||||
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
exp,
|
exp,
|
||||||
kind: "refresh".to_string(),
|
kind: "refresh".to_string(),
|
||||||
|
jti: Some(jti.clone()),
|
||||||
};
|
};
|
||||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||||
.map_err(|e| AppError::Internal(e.to_string()))
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok((token, jti))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a jti row into `refresh_tokens`. Must be called immediately after
|
||||||
|
/// [`make_refresh_token`] and before the token is sent to the client.
|
||||||
|
async fn store_refresh_jti(
|
||||||
|
pool: &sqlx::SqlitePool,
|
||||||
|
jti: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let expires_at = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
jti,
|
||||||
|
user_id,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -160,9 +189,13 @@ pub async fn register(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
||||||
|
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
||||||
|
store_refresh_jti(&state.pool, &refresh_jti, &user_id).await?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
access_token: make_access_token(&user_id, &state.jwt_secret)?,
|
access_token,
|
||||||
refresh_token: make_refresh_token(&user_id, &state.jwt_secret)?,
|
refresh_token,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,27 +223,74 @@ pub async fn login(
|
|||||||
return Err(AppError::InvalidCredentials);
|
return Err(AppError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access_token = make_access_token(&row_id, &state.jwt_secret)?;
|
||||||
|
let (refresh_token, refresh_jti) = make_refresh_token(&row_id, &state.jwt_secret)?;
|
||||||
|
store_refresh_jti(&state.pool, &refresh_jti, &row_id).await?;
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
access_token: make_access_token(&row_id, &state.jwt_secret)?,
|
access_token,
|
||||||
refresh_token: make_refresh_token(&row_id, &state.jwt_secret)?,
|
refresh_token,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
/// `POST /api/auth/refresh` — exchange a valid refresh token for a new token pair.
|
||||||
|
///
|
||||||
|
/// The incoming refresh token is consumed (its jti row is deleted) and a new
|
||||||
|
/// refresh token is issued. Using a consumed token returns 401. Tokens issued
|
||||||
|
/// before rotation was enabled (no `jti` claim) are also rejected with 401 —
|
||||||
|
/// the player must re-login once after upgrading the server.
|
||||||
|
///
|
||||||
|
/// Expired rows from other sessions are pruned on each successful call.
|
||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<RefreshRequest>,
|
Json(body): Json<RefreshRequest>,
|
||||||
) -> Result<Json<RefreshResponse>, AppError> {
|
) -> Result<Json<RefreshResponse>, AppError> {
|
||||||
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
|
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
|
||||||
|
|
||||||
|
// Tokens without jti predate rotation — require re-login.
|
||||||
|
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
// Verify this jti is still live (not yet consumed or from a deleted account).
|
||||||
|
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
|
||||||
|
let exists: Option<String> = sqlx::query_scalar!(
|
||||||
|
"SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||||
|
jti
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the old token before issuing new ones. If the insert below
|
||||||
|
// fails, the user loses this session (must re-login) — safe by design.
|
||||||
|
sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
|
||||||
|
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
|
||||||
|
store_refresh_jti(&state.pool, &new_jti, &claims.sub).await?;
|
||||||
|
|
||||||
|
// Prune expired rows from all sessions on each successful rotation.
|
||||||
|
// The expires_at index makes this a cheap index-backed scan.
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query!("DELETE FROM refresh_tokens WHERE expires_at < ?", now)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(RefreshResponse {
|
Ok(Json(RefreshResponse {
|
||||||
access_token: make_access_token(&claims.sub, &state.jwt_secret)?,
|
access_token: new_access,
|
||||||
|
refresh_token: new_refresh,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||||||
///
|
///
|
||||||
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
/// All related rows (sync_state, refresh_tokens, leaderboard) are removed
|
||||||
|
/// via `ON DELETE CASCADE` in the schema.
|
||||||
pub async fn delete_account(
|
pub async fn delete_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -229,7 +309,7 @@ mod tests {
|
|||||||
|
|
||||||
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
||||||
|
|
||||||
fn decode_token(token: &str) -> Claims {
|
fn decode_claims(token: &str) -> Claims {
|
||||||
let mut validation = Validation::default();
|
let mut validation = Validation::default();
|
||||||
validation.leeway = 60;
|
validation.leeway = 60;
|
||||||
decode::<Claims>(
|
decode::<Claims>(
|
||||||
@@ -244,27 +324,39 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn make_access_token_decodes_with_correct_claims() {
|
fn make_access_token_decodes_with_correct_claims() {
|
||||||
let token = make_access_token("user-123", TEST_SECRET).unwrap();
|
let token = make_access_token("user-123", TEST_SECRET).unwrap();
|
||||||
let claims = decode_token(&token);
|
let claims = decode_claims(&token);
|
||||||
assert_eq!(claims.sub, "user-123");
|
assert_eq!(claims.sub, "user-123");
|
||||||
assert_eq!(claims.kind, "access");
|
assert_eq!(claims.kind, "access");
|
||||||
|
assert!(claims.jti.is_none(), "access token must not carry a jti");
|
||||||
let now = Utc::now().timestamp() as usize;
|
let now = Utc::now().timestamp() as usize;
|
||||||
// expiry should be roughly 24 hours in the future (allow ±60s for test execution)
|
|
||||||
assert!(claims.exp > now + 86_400 - 60);
|
assert!(claims.exp > now + 86_400 - 60);
|
||||||
assert!(claims.exp < now + 86_400 + 60);
|
assert!(claims.exp < now + 86_400 + 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn make_refresh_token_decodes_with_correct_claims() {
|
fn make_refresh_token_decodes_with_correct_claims() {
|
||||||
let token = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
let (token, jti) = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
||||||
let claims = decode_token(&token);
|
let claims = decode_claims(&token);
|
||||||
assert_eq!(claims.sub, "user-456");
|
assert_eq!(claims.sub, "user-456");
|
||||||
assert_eq!(claims.kind, "refresh");
|
assert_eq!(claims.kind, "refresh");
|
||||||
|
assert_eq!(
|
||||||
|
claims.jti.as_deref(),
|
||||||
|
Some(jti.as_str()),
|
||||||
|
"jti in JWT must match returned jti"
|
||||||
|
);
|
||||||
|
assert!(!jti.is_empty(), "jti must be non-empty");
|
||||||
let now = Utc::now().timestamp() as usize;
|
let now = Utc::now().timestamp() as usize;
|
||||||
// expiry should be roughly 30 days in the future (allow ±60s for test execution)
|
|
||||||
assert!(claims.exp > now + 30 * 86_400 - 60);
|
assert!(claims.exp > now + 30 * 86_400 - 60);
|
||||||
assert!(claims.exp < now + 30 * 86_400 + 60);
|
assert!(claims.exp < now + 30 * 86_400 + 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn make_refresh_token_generates_unique_jtis() {
|
||||||
|
let (_, jti1) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||||
|
let (_, jti2) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||||
|
assert_ne!(jti1, jti2, "each call must produce a unique jti");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn make_access_token_wrong_secret_fails_decode() {
|
fn make_access_token_wrong_secret_fails_decode() {
|
||||||
let token = make_access_token("user-789", TEST_SECRET).unwrap();
|
let token = make_access_token("user-789", TEST_SECRET).unwrap();
|
||||||
@@ -279,9 +371,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn access_and_refresh_tokens_have_different_kinds() {
|
fn access_and_refresh_tokens_have_different_kinds() {
|
||||||
let access = make_access_token("u", TEST_SECRET).unwrap();
|
let access = make_access_token("u", TEST_SECRET).unwrap();
|
||||||
let refresh = make_refresh_token("u", TEST_SECRET).unwrap();
|
let (refresh, _jti) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||||||
let a_claims = decode_token(&access);
|
let a_claims = decode_claims(&access);
|
||||||
let r_claims = decode_token(&refresh);
|
let r_claims = decode_claims(&refresh);
|
||||||
assert_ne!(a_claims.kind, r_claims.kind);
|
assert_ne!(a_claims.kind, r_claims.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::{error::AppError, AppState};
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
/// The claims encoded in our JWT access tokens.
|
/// The claims encoded in our JWTs.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
/// Subject — the user's UUID string.
|
/// Subject — the user's UUID string.
|
||||||
@@ -24,6 +24,10 @@ pub struct Claims {
|
|||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
/// Token kind: `"access"` or `"refresh"`.
|
/// Token kind: `"access"` or `"refresh"`.
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
/// JWT ID — UUID v4 embedded in refresh tokens for rotation tracking.
|
||||||
|
/// Access tokens omit this field (`None`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jti: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The authenticated user identity injected into request extensions after
|
/// The authenticated user identity injected into request extensions after
|
||||||
@@ -135,6 +139,7 @@ mod tests {
|
|||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
exp,
|
exp,
|
||||||
kind: kind.to_string(),
|
kind: kind.to_string(),
|
||||||
|
jti: None,
|
||||||
};
|
};
|
||||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,9 +347,10 @@ async fn login_with_unknown_username_returns_401() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with a new access token.
|
/// `POST /api/auth/refresh` with a valid refresh token returns 200 with both
|
||||||
|
/// a new access token and a rotated refresh token.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_returns_new_access_token() {
|
async fn refresh_returns_new_access_and_refresh_tokens() {
|
||||||
|
|
||||||
let app = build_test_router(test_pool().await);
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
@@ -368,6 +369,80 @@ async fn refresh_returns_new_access_token() {
|
|||||||
body["access_token"].is_string(),
|
body["access_token"].is_string(),
|
||||||
"refresh must return a new access_token"
|
"refresh must return a new access_token"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
body["refresh_token"].is_string(),
|
||||||
|
"refresh must return a rotated refresh_token"
|
||||||
|
);
|
||||||
|
let rotated = body["refresh_token"].as_str().unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
rotated, refresh,
|
||||||
|
"rotated refresh token must differ from the original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a successful rotation, the old refresh token must be rejected (consumed).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consumed_refresh_token_is_rejected() {
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
|
let (_access, original_refresh) =
|
||||||
|
register_user(app.clone(), "grace_rot", "rotation_pass").await;
|
||||||
|
|
||||||
|
// First refresh — consumes original_refresh, returns a new one.
|
||||||
|
let resp1 = post_json(
|
||||||
|
app.clone(),
|
||||||
|
"/api/auth/refresh",
|
||||||
|
serde_json::json!({ "refresh_token": original_refresh }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp1.status(), StatusCode::OK, "first rotation must succeed");
|
||||||
|
|
||||||
|
// Second attempt with the now-consumed original token must fail.
|
||||||
|
let resp2 = post_json(
|
||||||
|
app,
|
||||||
|
"/api/auth/refresh",
|
||||||
|
serde_json::json!({ "refresh_token": original_refresh }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp2.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"consumed refresh token must return 401"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rotated refresh token must be usable for a subsequent refresh.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rotated_refresh_token_can_be_used_again() {
|
||||||
|
let app = build_test_router(test_pool().await);
|
||||||
|
|
||||||
|
let (_access, refresh) = register_user(app.clone(), "helen_rot", "pass_word_1").await;
|
||||||
|
|
||||||
|
// First rotation.
|
||||||
|
let resp1 = post_json(
|
||||||
|
app.clone(),
|
||||||
|
"/api/auth/refresh",
|
||||||
|
serde_json::json!({ "refresh_token": refresh }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp1.status(), StatusCode::OK);
|
||||||
|
let rotated = body_json(resp1).await;
|
||||||
|
let second_refresh = rotated["refresh_token"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Second rotation using the first rotated token.
|
||||||
|
let resp2 = post_json(
|
||||||
|
app,
|
||||||
|
"/api/auth/refresh",
|
||||||
|
serde_json::json!({ "refresh_token": second_refresh }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
resp2.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"rotated token must work for a second rotation"
|
||||||
|
);
|
||||||
|
let body2 = body_json(resp2).await;
|
||||||
|
assert!(body2["access_token"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
|
/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
|
||||||
|
|||||||
Reference in New Issue
Block a user