Compare commits

..

6 Commits

Author SHA1 Message Date
funman300 b129664344 feat(auth): refresh token rotation via jti tracking
Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.

Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:34:42 -07:00
funman300 7d7c83ab28 docs(architecture): update to v1.3 — all Phase 8 gaps closed
Adds solitaire_wasm crate (§2/§3), replay API endpoints (§9), web
replay player routes, SyncProvider 7 optional methods, ThemePlugin +
SyncSetupPlugin in plugin table (§5), Settings new fields (§8), and
DB migration 002 replays table (§7). Also fixes missing [0.23.0]
section header in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:25:58 -07:00
funman300 bd388fef26 docs(changelog): document Phase 8 sync UI (432061c–272d31f)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:02:39 -07:00
funman300 272d31f851 feat(sync): account deletion flow + handle_sync_buttons refactor
Adds a two-step account-deletion UX: "Delete Account" button in the
Settings sync section (visible only when server backend is configured)
fires DeleteAccountRequestEvent → SyncSetupPlugin opens a confirmation
modal. "Delete Forever" spawns an async delete_account task; on success
SyncLogoutRequestEvent clears local credentials and resets the backend.
Errors surface via InfoToast.

Also splits handle_settings_buttons into handle_settings_buttons +
handle_sync_buttons to stay within Bevy's 16-parameter system limit.
Sync buttons (Sync Now, Connect, Disconnect, Delete Account) are now
handled in the dedicated handle_sync_buttons system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:53:32 -07:00
funman300 6ce55646d8 feat(sync): re-auth prompt on expired session + server deployment artifacts
On auth failure during pull (access + refresh both expired), sync_plugin now
fires SyncConfigureRequestEvent so the Connect modal reopens automatically
instead of leaving the player with a silent error status. The modal's existing
double-open guard keeps repeated failures idempotent.

Also removes the unused SyncAuthResultEvent (results handled inline in
SyncSetupPlugin via PendingAuthTask polling) and adds server deployment
artifacts: Dockerfile (multi-stage, SQLX_OFFLINE), docker-compose.yml (SQLite
volume, health-check), and .env.example for local development setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:45:08 -07:00
funman300 432061c3ec feat(sync): Phase 8 sync setup UI — login/register modal + Connect/Disconnect
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.

Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:40:29 -07:00
21 changed files with 1818 additions and 392 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
}
+79 -3
View File
@@ -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
@@ -584,7 +631,20 @@ pub struct Settings {
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` |
+83
View File
@@ -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
View File
@@ -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-12ARCHITECTURE.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.4v0.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 AC. 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.
``` ```
+2 -1
View File
@@ -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)
+87 -7
View File
@@ -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()))
} }
+17
View File
@@ -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 /
+2
View File
@@ -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,
+149 -38
View File
@@ -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,28 +2270,17 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
parent
.spawn(Node { // Helper closure to spawn a small settings-style pill button.
flex_direction: FlexDirection::Row, let small_button = |row: &mut ChildSpawnerCommands,
align_items: AlignItems::Center, marker: SettingsButton,
column_gap: VAL_SPACE_3, label: &str,
..default() tooltip: String,
}) font: TextFont| {
.with_children(|row| {
row.spawn(( row.spawn((
SyncStatusText, marker,
Text::new(status_text.to_string()),
status_font,
TextColor(TEXT_SECONDARY),
));
// ManualSyncRequestEvent is always registered, so this
// button is safe to show even when SyncPlugin is absent.
row.spawn((
SettingsButton::SyncNow,
Button, Button,
Tooltip::new( Tooltip::new(tooltip),
"Push and pull stats now. Runs automatically on launch and exit.",
),
Node { Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
@@ -2255,11 +2294,82 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
Text::new("Sync Now"), Text::new(label.to_string()),
button_font, font,
TextColor(TEXT_PRIMARY), TextColor(TEXT_PRIMARY),
)); ));
}); });
};
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
// Status line + inline action buttons.
col.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|row| {
row.spawn((
SyncStatusText,
Text::new(status_text.to_string()),
status_font,
TextColor(TEXT_SECONDARY),
));
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"
); );
} }
+18 -2
View File
@@ -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)));
} }
+876
View File
@@ -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)
}
+13
View File
@@ -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
+57
View File
@@ -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"]
+25
View File
@@ -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
View File
@@ -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);
} }
+6 -1
View File
@@ -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()
} }
+77 -2
View File
@@ -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