From 9a6fb3933e94c9b3e155e218e6cd5e97f6afbcf1 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 12:59:05 +0200 Subject: [PATCH 1/6] docs: move the docs pages into guide/ and internals/ with kebab-case names --- .../configuration.md} | 0 .../api-integrations.md} | 0 .../architecture.md} | 0 .../data-model.md} | 0 .../development.md} | 0 env.example | 21 ------------------- 6 files changed, 21 deletions(-) rename docs/{CONFIGURATION.md => guide/configuration.md} (100%) rename docs/{API_INTEGRATIONS.md => internals/api-integrations.md} (100%) rename docs/{ARCHITECTURE.md => internals/architecture.md} (100%) rename docs/{DATA_MODEL.md => internals/data-model.md} (100%) rename docs/{DEVELOPMENT.md => internals/development.md} (100%) delete mode 100644 env.example diff --git a/docs/CONFIGURATION.md b/docs/guide/configuration.md similarity index 100% rename from docs/CONFIGURATION.md rename to docs/guide/configuration.md diff --git a/docs/API_INTEGRATIONS.md b/docs/internals/api-integrations.md similarity index 100% rename from docs/API_INTEGRATIONS.md rename to docs/internals/api-integrations.md diff --git a/docs/ARCHITECTURE.md b/docs/internals/architecture.md similarity index 100% rename from docs/ARCHITECTURE.md rename to docs/internals/architecture.md diff --git a/docs/DATA_MODEL.md b/docs/internals/data-model.md similarity index 100% rename from docs/DATA_MODEL.md rename to docs/internals/data-model.md diff --git a/docs/DEVELOPMENT.md b/docs/internals/development.md similarity index 100% rename from docs/DEVELOPMENT.md rename to docs/internals/development.md diff --git a/env.example b/env.example deleted file mode 100644 index 108b06a..0000000 --- a/env.example +++ /dev/null @@ -1,21 +0,0 @@ -# Spotter — Environment Variables -# -# These are NOT required for basic usage. Spotter keeps the credentials you -# enter in the app in the OS keyring (or, when none is available, in its -# local SQLite database). -# -# Set XDG_DATA_HOME to override the default data directory: -# XDG_DATA_HOME=/path/to/custom/dir -# -# ── Platform API keys (configured in Spotter's Profile UI) ── -# -# Steam: Get an API key at https://steamcommunity.com/dev/apikey -# STEAM_API_KEY= -# -# Xbox: Get a free API key at https://xbl.io -# XBOX_API_KEY= -# -# PlayStation: Get NPSSO token from https://ca.account.sony.com/api/v1/ssocookie -# PSN_NPSSO= -# -# GOG: Uses OAuth login flow — no manual API key needed From 66b2f7351cc4a3477d37eb295bdbaac05bee3cfd Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 13:06:48 +0200 Subject: [PATCH 2/6] docs: rewrite the README to the Colony layout and sort the docs by reader --- README.md | 291 +++++++++++++++++++---------- SECURITY.md | 53 ++++++ docs/README.md | 21 +++ docs/guide/configuration.md | 186 +++++++++--------- docs/guide/platform-setup.md | 105 +++++++++++ docs/internals/api-integrations.md | 257 ++++++++++++------------- docs/internals/architecture.md | 241 +++++++++++++----------- docs/internals/data-model.md | 265 +++++++++++++------------- docs/internals/development.md | 182 +++++++----------- 9 files changed, 913 insertions(+), 688 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/README.md create mode 100644 docs/guide/platform-setup.md diff --git a/README.md b/README.md index 6d63454..4a11dc2 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,225 @@ -# Spotter +
+ + + + Spotter + + +**A local game library and statistics tracker for players whose games are spread across Steam, GOG, Epic Games, Xbox and PlayStation.** + +
+ +[![License: GPL-3.0-or-later](https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg)](LICENSE) +[![Colony app](https://img.shields.io/badge/Colony-game-purple)](https://github.com/Project-Colony/Colony) +[![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20windows%20%7C%20macOS-lightgrey)](#installation) + +Your games are spread across five stores, each with its own launcher, its own +playtime counter and its own idea of what you finished. Spotter imports them +into one SQLite database on your own machine, keeps your status, rating and +notes for every game across re-imports, and turns the result into playtime and +completion statistics. Nothing leaves your computer except the requests to the +stores you import from. + +> **Status:** every importer is tested against recorded Steam, GOG, Epic, +> Xbox and PlayStation responses, not against live accounts. Only Steam's Web +> API and OpenXBL are documented APIs: the Steam store and achievement pages +> are scraped, and the GOG, Epic and PlayStation endpoints are the ones their +> own clients use, so a store can break an import without notice until the +> next release. Platform tokens live in the operating system's keyring since +> 0.3.0. Every +> release asset is signed with the Project Colony ed25519 key; the Windows +> executable has no Authenticode signature yet, pending SignPath Foundation +> approval. The Colony Preferences page, the French translation and the move +> to the standard Colony folders are covered by unit tests and still need a +> check on a real desktop install. + +## Why Spotter + +- **Every store, one list.** Steam, GOG, Epic Games, Xbox (through OpenXBL) + and PlayStation Network imports land in the same library, and Nintendo games + are added by hand. Each store's launcher only shows its own games. +- **Your data stays yours.** The library is a local SQLite file. There is no + account, no sync server and no telemetry; exports are plain JSON and CSV. +- **Re-import without losing anything.** A second import updates playtime, + achievements and store details, and never touches the status, rating or + notes you set. +- **Tokens in the keyring.** API keys and login tokens go to Windows Credential + Manager, the macOS Keychain or the Secret Service. Only a system without a + usable keyring falls back to a file, and Spotter tells you when it does. -A cross-platform game library tracker and statistics dashboard built with [Rust](https://www.rust-lang.org/) and [Iced](https://iced.rs). - -## Features - -- **Multi-platform imports** - Steam, GOG, Epic Games, Xbox (via OpenXBL), and PlayStation Network -- **Achievement tracking** - view and track achievements with icons, descriptions, and unlock dates -- **Statistics dashboard** - playtime charts, status/platform distribution, most played games -- **Customizable UI** - dark/darker/midnight themes, accent colors, UI scaling, sidebar width, compact mode -- **Accessibility** - high contrast mode, large click targets, status label toggles -- **Settings** - date format, notification preferences, toast duration, start screen, default status/platform -- **Local-first** - all data stored in a local SQLite database, no cloud dependency - -## Running the binaries - -The easiest way to install Spotter is through [Colony](https://github.com/Project-Colony/Colony), which downloads the right binary and checks its signature for you. - -To run a release by hand, download the asset for your system from the [latest release](https://github.com/Project-Colony/Spotter/releases/latest): +``` +Steam ─┐ +GOG ───┤ +Epic ──┼──► import + merge ──► spotter.db ──► library, statistics, exports +Xbox ──┤ (local) +PSN ───┘ Nintendo: added by hand +``` -| Asset | System | +## What it does + +- **Imports** from Steam (Web API key, Steam login), GOG (browser login), Epic + Games (account login for the whole library, or a scan of the games installed + through the Epic Games Launcher or Legendary), Xbox (an OpenXBL API key) and + PlayStation Network (an NPSSO token). +- **Merges** each import into the existing library by store id, then by title, + so importing again does not duplicate your games. +- **Achievements**: the full list with unlock dates for Steam and Xbox (with + icons for Steam), trophy counts for PlayStation, the total for Epic Games. +- **Statistics**: totals, average rating, playtime over the last 30 days, + this month against last month, status and platform distribution, and the + most played games. +- **Library tools**: search, status and platform filters, six sort orders, + favourites, ratings, notes, and manual entries. +- **Backups**: JSON and CSV export, JSON import, and an automatic JSON backup + after every import. +- **Colony Preferences**: the shared Colony themes and accents, high contrast, + a dyslexia-friendly font, text scaling, reduced motion, and an English or + French interface. + +## Installation + +### Via Colony (recommended) + +Search for **Spotter** in [Colony](https://github.com/Project-Colony/Colony) +and install it. Colony checks the release signature before it installs, and +updates arrive through the launcher. + +### Direct binary download + +Grab the asset for your platform from the +[latest release](https://github.com/Project-Colony/Spotter/releases/latest): + +| Platform | Asset | |---|---| -| `spotter-linux` | Linux x86_64 | -| `spotter-windows.exe` | Windows x86_64 | -| `spotter-macos-arm` | macOS on Apple silicon | -| `spotter-macos-x86` | macOS on Intel | +| Linux | `spotter-linux` | +| Windows | `spotter-windows.exe` | +| macOS (Apple Silicon) | `spotter-macos-arm` | +| macOS (Intel) | `spotter-macos-x86` | -Each asset has a matching `.sig` file, an ed25519 signature made with the Project-Colony release key, and a signed `.meta` file binding it to its version. +```bash +chmod +x spotter-linux && ./spotter-linux +``` -- **Linux**: `chmod +x spotter-linux && ./spotter-linux`. It needs a Wayland or X11 session and libxkbcommon, which every desktop distribution ships. -- **Windows**: run `spotter-windows.exe`. Until it carries an Authenticode signature (see [Code signing policy](#code-signing-policy)), SmartScreen may ask you to confirm with **More info** then **Run anyway**. -- **macOS**: `chmod +x spotter-macos-arm && xattr -d com.apple.quarantine spotter-macos-arm && ./spotter-macos-arm` (use `spotter-macos-x86` on Intel). The binary is not notarized, so Gatekeeper blocks it until the quarantine attribute is removed. +- **Linux** needs a Wayland or X11 session and libxkbcommon, which every + desktop distribution ships. +- **Windows**: until the executable carries an Authenticode signature, + SmartScreen may ask you to confirm with **More info** then **Run anyway**. +- **macOS**: the binary is not notarized, so remove the quarantine attribute + first: + `chmod +x spotter-macos-arm && xattr -d com.apple.quarantine spotter-macos-arm && ./spotter-macos-arm` + (use `spotter-macos-x86` on Intel). `spotter --version` prints the version and exits without opening a window. +To connect your stores, see +[docs/guide/platform-setup.md](docs/guide/platform-setup.md). -## Building - -Requires Rust 1.90+ (2021 edition). +### Build from source ```bash +git clone https://github.com/Project-Colony/Spotter +cd Spotter cargo build --release ``` -The binary will be at `target/release/spotter`. +Requires Rust 1.90 or newer. On Linux, install the windowing and keyring +development packages first; the list is in +[docs/internals/development.md](docs/internals/development.md). -## Running +## Documentation -```bash -cargo run -``` +Full documentation is in [docs/](docs/). Start at +[docs/README.md](docs/README.md). -On first launch, Spotter creates a sample library. To import your own games, go to **Import** and configure your platform credentials in **Profile**. +## Code signing policy -### Platform Setup +Every release asset is signed by the Project Colony organisation in CI, never on +a developer machine. Next to each asset on the release page: -| Platform | Credential needed | Where to get it | -|---|---|---| -| Steam | API Key + Steam ID | [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) - or use the "Login with Steam" button | -| GOG | OAuth token | GOG Galaxy client settings | -| Epic | - | Auto-scans local Epic Games Launcher | -| Xbox | OpenXBL API key | [xbl.io](https://xbl.io) | -| PlayStation | NPSSO token | Browser cookies at [store.playstation.com](https://store.playstation.com) | -| Nintendo | - | Manual entry (no import API) | +| File | What it is | +|---|---| +| `.sig` | an ed25519 signature over the asset, made with the organisation's release key | +| `.meta` | three lines binding the asset to its file name, its sha256 and the release version | +| `.meta.sig` | an ed25519 signature over the `.meta` | -## Testing +The private key is an organisation secret, used only by the shared +[sign-and-publish workflow](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/.github/workflows/sign-and-publish.yml) +in a job that builds nothing; the jobs that compile Spotter never see it. +Colony checks all three files before it installs or updates Spotter, and +refuses a release older than the one installed. To check a download yourself +with OpenSSL 3 (the same commands work for every asset): ```bash -cargo test -- --test-threads=1 +cat > colony-release.pub <<'EOF' +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEARNjg3Nn8H6/aBg1unwGjkUTcrdTxERNefVaqU8cFu0s= +-----END PUBLIC KEY----- +EOF +a=spotter-linux +openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a" -sigfile "$a.sig" +openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a.meta" -sigfile "$a.meta.sig" +cat "$a.meta" # version=, asset=, sha256= +sha256sum "$a" # the digest must equal the sha256 line ``` -Single-threaded execution is required because database tests use shared environment state (`XDG_DATA_HOME`). - -## Project Structure +How releases are built, signed and published: +[design/releases.md](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/design/releases.md#5-signing). -``` -src/ - main.rs Entry point - lib.rs Module tree; public modules for tests - app.rs Application state, messages, update loop - db.rs SQLite database operations - keyring.rs Platform tokens in the OS keyring - models.rs Data structures (Game, Platform, Settings, etc.) - theme.rs Color constants and ViewTheme - steam.rs Steam import + HTML scraping - steam_auth.rs Steam browser login flow - gog.rs GOG Galaxy import - epic.rs Epic Games Launcher scanner - xbox.rs Xbox Live import via OpenXBL - playstation.rs PSN import via trophy API - images.rs Cover art + achievement icon caching - views/ UI views (library, detail, stats, settings, etc.) -tests/ - unit_tests.rs Unit + integration tests -``` + -### Privacy policy +## Privacy -Spotter has no telemetry, no analytics and no update check, and sends nothing to its developers. Your library stays in the local database. Your platform credentials (the API keys you enter and the login tokens Spotter receives) are kept in your operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux, such as GNOME Keyring or KWallet); when no keyring is available, Spotter keeps them unencrypted in the local database instead, and says so on its Profile page. Spotter only contacts the services below, for the features that need them: +Spotter sends no telemetry, no analytics and no crash reports, has no update +check, and sends nothing to its developers. It contacts a store only when you +use a feature that needs it. -- **Imports, when you start one** from the Import screen. Spotter contacts the platform you picked and sends it the credentials you entered for it, plus the ids of your games: - - Steam: `api.steampowered.com` (your Web API key and Steam ID, to list owned games), `steamcommunity.com` (your Steam ID, to read achievements from your profile) and `store.steampowered.com` (game details). - - GOG: `auth.gog.com` (the login code you paste, exchanged for a token), then `embed.gog.com` and `api.gog.com` with that token. - - Epic Games: `account-public-service-prod.ol.epicgames.com` (the login code you paste, exchanged for a token), then Epic's library, catalog, entitlement and launcher services and `store.epicgames.com` with that token and your account id. The Epic Games Launcher scan reads your installed games from disk, then asks Epic's store for their details. - - Xbox: `xbl.io` (your OpenXBL API key). - - PlayStation: `ca.account.sony.com` (your NPSSO token, exchanged for an access token), then `m.np.playstation.com` with that token. -- **Steam login**, when you use it: Spotter opens Steam's sign-in page in your browser, receives the answer on a local port of your own computer (127.0.0.1) and asks `steamcommunity.com` to confirm it. -- **Cover art and achievement icons**, downloaded from the image addresses stored with each game, which come from those platforms (or from a JSON backup you import): covers after an import, unless you turn off automatic cover downloads in Settings; achievement icons when you open a Steam game's page. -- **At startup**, if you have connected GOG or Epic Games, Spotter renews that login automatically by sending the saved refresh token to `auth.gog.com` or `account-public-service-prod.ol.epicgames.com`. - -Links such as a game's store page open in your browser; Spotter itself sends nothing for them. +| Data | Stored or sent | Where, and why | +|---|---|---| +| Your library | stored | `library/spotter.db` in Spotter's data folder: games, playtime history, achievements, your profile name and connected account ids | +| Preferences | stored | `preferences/preferences.json` in Spotter's config folder | +| Credentials | stored | your Steam API key, OpenXBL key, PlayStation NPSSO token and the GOG and Epic login tokens, in the operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux such as GNOME Keyring or KWallet). Without a usable keyring they are written unencrypted to `credentials/` in the config folder (owner-only on Linux and macOS), and the Profile page says so | +| Exports and backups | stored | `exports/` in the data folder: `spotter_export.json` and `spotter_export.csv` when you export, `spotter_auto_backup.json` after every import | +| Import log | stored | `logs/import.log` in the data folder (rotated to `import.log.1` at 5 MB): import steps, game counts and errors, plus your Xbox user id | +| Covers and achievement icons | stored | `covers/` and `achievement_icons/` in Spotter's cache folder; safe to delete, they are downloaded again | +| Steam import | sent | when you import: your Web API key and Steam ID to `api.steampowered.com` (owned games), your Steam ID to `steamcommunity.com` (achievements) and game ids to `store.steampowered.com` (store details) | +| Steam login | sent | when you click Login: Steam's sign-in page opens in your browser, the answer comes back to a listener on `127.0.0.1` (ports 29876 to 29880) of your own computer, and Spotter asks `steamcommunity.com` to confirm it | +| GOG import | sent | when you log in: the code you paste goes to `auth.gog.com` in exchange for a token; when you import, that token goes to `embed.gog.com` (your games) and game ids to `api.gog.com` (store details) | +| Epic Games import | sent | when you log in: the code you paste goes to `account-public-service-prod.ol.epicgames.com`; when you import, the token and your account id go to Epic's library, entitlement, launcher and catalog services (`*.epicgames.com`), and game ids to `store.epicgames.com` and `store-content-ipv4.ak.epicgames.com`. The local scan reads installed games from disk, then asks the store for their details | +| Xbox import | sent | your OpenXBL API key to `xbl.io` (titles, achievements, store details) | +| PlayStation import | sent | your NPSSO token to `ca.account.sony.com` in exchange for an access token, then that token to `m.np.playstation.com` (trophies and played games) | +| Token renewal | sent | at startup if GOG or Epic Games is connected, and when an import finds the login expired: the saved refresh token to `auth.gog.com` or `account-public-service-prod.ol.epicgames.com` | +| Images | sent | requests for the cover and achievement image addresses stored with each game, which come from those stores (or from a JSON backup you import): covers after an import unless automatic cover downloads are off, Steam achievement icons during a Steam import and when you open a Steam game | + +The config, data and cache folders are `Colony/Spotter/` under your system's +config, data and cache directories; the full table per operating system is in +[docs/internals/data-model.md](docs/internals/data-model.md#where-the-data-lives). + +Spotter connects to no server except the ones in this table, and only for the +action named there. Links such as a game's store page open in your browser; +Spotter itself sends nothing for them. Each store's own privacy policy covers +what it does with those requests: +[Steam](https://store.steampowered.com/privacy_agreement/), +[GOG](https://support.gog.com/hc/articles/212632109-Privacy-Policy?product=gog), +[Epic Games](https://www.epicgames.com/site/privacypolicy), +[OpenXBL](https://xbl.io/agreement), +[PlayStation](https://www.playstation.com/legal/privacy-policy/). ## License -GPL-3.0-or-later - see [LICENSE](LICENSE). +GPL-3.0-or-later. See [LICENSE](LICENSE). + +The interface fonts come with +[colony-ui](https://github.com/Project-Colony/Project-Colony-Resources/tree/main/crates/colony-ui): +JetBrains Mono Nerd Font, OpenDyslexic and Font Awesome 6 Free, each under the +SIL Open Font License 1.1, with their licence files in +[colony-ui's assets/fonts](https://github.com/Project-Colony/Project-Colony-Resources/tree/main/crates/colony-ui/assets/fonts). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b9fb084 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +Spotter holds the API keys and login tokens of up to five game stores, signs in +to them, and parses the JSON and HTML those stores send back. A flaw in any of +that is a security bug, and reports are welcome. + +## Supported versions + +Only the **latest release** receives security fixes. Spotter ships as a single +binary, through Colony and the release page, so the fix for a vulnerability is +the next release, not a patch to an older one. + +| Version | Supported | +| --- | --- | +| latest release | yes | +| anything older | no | + +## Reporting a vulnerability + +Please report vulnerabilities **privately** via +[GitHub Security Advisories](https://github.com/Project-Colony/Spotter/security/advisories/new) +("Report a vulnerability"). Do not open a public issue for exploitable bugs, +and do not attach real API keys, tokens or an unredacted `spotter.db` to a +report. + +Include the version (`spotter --version`), the platform, and the steps or the +file that reproduce it. The report stays private until a fixed release is out, +and you are credited in the release notes if you want to be. + +## Scope + +Reports of particular interest: + +- **Credentials**: a store token or API key written anywhere other than the + operating system's keyring while a keyring is usable, a fallback file in + `credentials/` readable by other users, or a secret that reaches a log, an + export, a URL or the wrong host. +- **The Steam login listener**: Spotter accepts the OpenID answer on + `127.0.0.1` (ports 29876 to 29880) and confirms it with `steamcommunity.com` + before trusting the Steam ID. A way to make it accept a forged answer, or to + reach it from another machine, is in scope. +- **Untrusted input**: crashes or worse from store responses, scraped HTML, + downloaded images, Epic Games Launcher, Heroic or Legendary manifests, or a + crafted JSON backup imported from the Profile page, including file names + derived from game titles that escape the cache folder. +- **Release signing**: anything that gets an asset accepted by Colony without a + valid signature from the Project Colony release key. See + [Code signing policy](README.md#code-signing-policy). + +Out of scope: what the stores themselves do with the requests Spotter sends +them, and the well-known public OAuth client ids of the GOG Galaxy, Epic Games +Launcher and PlayStation apps, which Spotter uses the same way every +open-source client does. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..66d5c05 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Spotter documentation + +Sorted by who is reading, not by subject. + +## Using Spotter + +| | | +|---|---| +| [guide/platform-setup.md](guide/platform-setup.md) | what each store needs before you can import from it, and where to get it | +| [guide/configuration.md](guide/configuration.md) | every preference, what it changes, and where the preferences file lives | + +## Reading the code + +| | | +|---|---| +| [internals/architecture.md](internals/architecture.md) | how a click becomes a state change, a background task and a database write | +| [internals/data-model.md](internals/data-model.md) | the game, profile and achievement records, the SQLite schema, and where every file lives | +| [internals/api-integrations.md](internals/api-integrations.md) | which endpoints each importer calls, how it signs in, and what it reads | +| [internals/development.md](internals/development.md) | building, testing, and the conventions a change should follow | + +Security policy: [../SECURITY.md](../SECURITY.md). diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 38386a4..1c96ac6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,100 +1,90 @@ # Configuration -All settings are stored as a JSON blob in the SQLite `settings` table and applied immediately on change. - -## Settings Overview - -### General - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `default_status` | GameStatus | Unplayed | Default status for new games | -| `default_platform` | Platform | Steam | Default platform for new games | -| `confirm_before_delete` | bool | true | Show confirmation dialog before deleting a game | -| `start_screen` | StartScreen | Library | Screen shown on launch (Library / Statistics / Import / Profile) | -| `notifications_enabled` | bool | true | Show toast notifications for success messages | -| `toast_duration` | ToastDuration | Normal (4s) | Auto-dismiss duration: Short (2s), Normal (4s), Long (8s) | -| `date_format` | DateFormat | YYYY-MM-DD | Date display: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY | -| `default_sort_order` | SortOrder | Title A-Z | Default library sort | -| `remember_filters` | bool | false | Persist filter selections across sessions | -| `show_game_descriptions` | bool | false | Show store descriptions in library cards | -| `achievements_display` | AchievementsDisplay | Full | Full list / Summary only / Hidden | -| `download_covers_auto` | bool | true | Automatically download cover art on import | - -### Appearances - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `theme_mode` | ThemeMode | Dark | Dark / Darker / Midnight | -| `accent_color` | AccentColor | Blue | Blue / Purple / Green / Gold / Red / Cyan | -| `compact_list` | bool | false | Reduced spacing in game list (4px vs 8px) | -| `show_covers_in_list` | bool | true | Show cover thumbnails in library | -| `ui_scale` | UiScale | Normal | Small (0.85x) / Normal (1.0x) / Large (1.2x) | -| `sidebar_width` | u16 | 220 | Sidebar width in pixels (160-400, presets: 180/220/280) | - -### Accessibility - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `high_contrast` | bool | false | Increase text/border contrast | -| `show_status_labels` | bool | true | Show text labels alongside status dots | -| `large_click_targets` | bool | false | Increase button padding (8px → 12px) | - -## Theme Modes - -Three dark theme variants with progressively darker backgrounds: - -| Element | Dark | Darker | Midnight | -|---------|------|--------|----------| -| Main bg | `rgb(0.10, 0.10, 0.14)` | `rgb(0.06, 0.06, 0.09)` | `rgb(0.02, 0.02, 0.05)` | -| Sidebar bg | `rgb(0.08, 0.08, 0.12)` | `rgb(0.04, 0.04, 0.07)` | `rgb(0.01, 0.01, 0.03)` | -| Card bg | `rgb(0.12, 0.12, 0.16)` | `rgb(0.08, 0.08, 0.11)` | `rgb(0.04, 0.04, 0.07)` | -| Row bg | `rgb(0.09, 0.09, 0.13)` | `rgb(0.06, 0.06, 0.09)` | `rgb(0.03, 0.03, 0.05)` | - -## Accent Colors - -| Name | RGB | Usage | -|------|-----|-------| -| Blue | `rgb(0.6, 0.8, 1.0)` | Default. Sidebar title, active tabs, filter chips | -| Purple | `rgb(0.7, 0.5, 0.9)` | Alternative accent | -| Green | `rgb(0.4, 0.9, 0.5)` | Nature/organic theme | -| Gold | `rgb(1.0, 0.85, 0.3)` | Warm/premium theme | -| Red | `rgb(1.0, 0.45, 0.45)` | Bold/aggressive theme | -| Cyan | `rgb(0.3, 0.9, 0.9)` | Cool/tech theme | - -## High Contrast Mode - -When enabled, text and border colors shift toward higher luminance: - -| Element | Normal | High Contrast | -|---------|--------|---------------| -| Border | `rgb(0.18, 0.18, 0.24)` | `rgb(0.35, 0.35, 0.45)` | -| Text Muted | `rgb(0.5, 0.5, 0.6)` | `rgb(0.6, 0.6, 0.7)` | -| Text Dim | `rgb(0.4, 0.4, 0.5)` | `rgb(0.5, 0.5, 0.6)` | -| Text Secondary | `rgb(0.6, 0.6, 0.7)` | `rgb(0.7, 0.7, 0.8)` | -| Text Light | `rgb(0.7, 0.7, 0.8)` | `rgb(1.0, 1.0, 1.0)` | - -## Style Helpers - -Reusable style functions in `theme.rs`: - -| Function | Description | -|----------|-------------| -| `card_style(bg, border, radius)` | Rounded card container | -| `chip_style(active, text, accent, radius)` | Filter chip toggle | -| `transparent_btn_style()` | Invisible button wrapper | -| `primary_btn_style(bg)` | Solid filled action button | -| `outline_btn_style(color, bg)` | Outlined button | -| `disabled_btn_style()` | Grayed-out disabled button | -| `loading_btn_style()` | Gold-tinted importing state | -| `accent_card_style(bg, accent)` | Card with colored left border | -| `dot_style(color, size)` | Small circular indicator | -| `pill_style(bg, border)` | Rounded status badge | - -## Settings Schema Versioning - -Settings include a `version` field (currently `1`) with `#[serde(default)]` on newer fields. This allows older settings JSON to deserialize without breaking, with new fields taking their defaults. - -`db::load_settings` also reads the stored JSON one field at a time when the whole struct does not parse: a value this build does not accept (for example a theme added by a newer version) falls back to that field's default, and every other setting keeps its stored value. - -If the startup load fails anyway, Spotter shows an error and runs on defaults, and refuses every write of saved data for the rest of the session: settings, profile and game saves, deletions, exports and the auto-backup are all refused (`Spotter::spawn_write`), so the data on disk stays as it was. +Every preference is set on the **Preferences** page. Open it with the +**Spotter** button at the top of the sidebar; the same button, or **Close**, +takes you back to the screen you were on. A change applies at once and is +saved at once: there is no Save button. + +The page lists the categories every Colony program shares, **General**, +**Appearance** and **Accessibility**, then Spotter's own, **Library** and +**Imports**. Each category is a column of sections that start collapsed; click +a section's title to open it. + +## The preferences file + +Preferences are one JSON file, `preferences/preferences.json`, in Spotter's +config folder: + +| System | Config folder | +|---|---| +| Linux | `~/.config/Colony/Spotter/` | +| Windows | `%LOCALAPPDATA%\Colony\Spotter\` | +| macOS | `~/Library/Application Support/Colony/Spotter/` | + +You can edit it while Spotter is closed. A missing field takes its default, and +a value Spotter does not understand (for example one written by a newer +version) falls back to its default while every other field keeps its value. +If the file cannot be read at all, Spotter starts on defaults, says so, and +refuses to save anything for the rest of the session, so the file on disk stays +as it was. + +The file also holds a little state that is not a preference: your favourite +games and, when **Remember filters** is on, the last status and platform +filters. + +## Shared Colony preferences + +These come from colony-ui, look and behave the same in every Colony program, +and are stored under the same keys. + +| Where | Preference | Key | Default | What it changes | +|---|---|---|---|---| +| Appearance, Theme | theme | `selected_theme`, `selected_variant` | Gruvbox, dark | the colour theme, picked from the shared Colony theme list | +| Appearance, Colors & accents | accent | `selected_accent` | none | one of the eight shared accent colours; none keeps the theme's own | +| Appearance, Colors & accents | Auto accent from background | `auto_accent` | off | uses the theme's own accent instead of the one picked | +| Appearance, Typography | Text size | `font_size` | default | small, default or large | +| Appearance, Effects | Animations | `animations` | on | animated transitions and fades | +| Accessibility, Vision | High contrast | `high_contrast` | off | stronger text and border contrast | +| Accessibility, Vision | Dyslexia-friendly font | `dyslexia_font` | off | switches the interface to OpenDyslexic | +| Accessibility, Motion | Reduce motion | `reduce_motion` | off | turns every animation off, whatever **Animations** says, and imported games appear at once instead of one by one | +| Accessibility, Reading | Text size | `text_size_a11y` | default | small, default, large or extra large, multiplied with the Typography size: text ranges from 0.72x to 1.68x | + +**Language** is in General: English or French. On the first start Spotter +follows the system language, and falls back to English. + +## Spotter's own preferences + +| Preference | Key | Default | What it changes | +|---|---|---|---| +| Default status for new games | `default_status` | Unplayed | the status pre-selected in **Add Game** | +| Default platform | `default_platform` | Steam | the platform pre-selected in **Add Game** | +| Confirm before deleting | `confirm_before_delete` | on | asks before a game is removed | +| Start screen | `start_screen` | Library | Library, Statistics, Import or Profile | +| Show notifications | `notifications_enabled` | on | success messages; errors always show | +| Toast duration | `toast_duration` | Normal | how long a message stays: Short (2 s), Normal (4 s), Long (8 s) | +| Date format | `date_format` | YYYY-MM-DD | or DD/MM/YYYY, or MM/DD/YYYY | +| Default sort order | `default_sort_order` | Title A-Z | the library's sort when Spotter starts | +| Remember filters | `remember_filters` | on | keeps the status and platform filters between sessions | +| Show descriptions in library | `show_game_descriptions` | off | store descriptions on the library cards | +| Achievements display | `achievements_display` | Full | the full list, a summary, or nothing on a game's page | +| Compact library list | `compact_list` | off | less space between games | +| Show cover images in library | `show_covers_in_list` | on | cover thumbnails next to the titles | +| Sidebar width | `sidebar_width` | 220 | in pixels, 160 to 400 | +| Auto-download covers on import | `download_covers_auto` | on | downloads covers after every import | +| Show status labels | `show_status_labels` | on | a text label next to each status colour, so status does not rely on colour alone | +| Large click targets | `large_click_targets` | off | more padding on buttons | + +## Upgrading from Spotter 0.3 or older + +Older versions kept everything in one `Colony/Spotter/` folder and their +settings inside the database. On the first start of a newer version, Spotter: + +- copies the database, the exports and the import logs to the folders listed in + [data-model.md](../internals/data-model.md#where-the-data-lives); +- writes the old settings to `preferences.json`, mapping the old Dark, Darker + and Midnight themes and the six old accents to the nearest shared theme and + accent, and the old UI scale to the text size; +- leaves the old folder untouched, so nothing is lost if something goes wrong. + Delete it yourself once your library is all there. + +Covers and achievement icons are not copied: they download again. diff --git a/docs/guide/platform-setup.md b/docs/guide/platform-setup.md new file mode 100644 index 0000000..43dfa2a --- /dev/null +++ b/docs/guide/platform-setup.md @@ -0,0 +1,105 @@ +# Platform setup + +Everything below happens on the **Import** screen: each store has a card with +its steps, a status pill (Setup required, Ready, Importing) and an import +button. What you enter there is kept in the operating system's keyring, see +[Privacy](../../README.md#privacy). + +| Store | What Spotter needs | Where to get it | +|---|---|---| +| Steam | your Steam ID and a Steam Web API key | the **Login** button, then [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | +| GOG | a GOG login | the **Login** button, then paste the address GOG redirects you to | +| Epic Games | nothing, or an Epic login for the whole library | the **Login** button, then paste the answer Epic shows | +| Xbox | an OpenXBL API key | [xbl.io](https://xbl.io) (free account) | +| PlayStation | an NPSSO token | [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie) while signed in to PlayStation | +| Nintendo | nothing | no import: add games with **Add Game** | + +## Steam + +1. Click **Login**. Steam's sign-in page opens in your browser. Sign in and + approve; Steam sends you back to a page served by Spotter on `127.0.0.1`, + and the card shows your Steam ID. Spotter waits two minutes for the answer. +2. Create a Web API key at + [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) (any + domain name works) and paste it into **Steam Web API key**. +3. Click **Import Steam**. + +Achievements are read from your Steam community profile, so set **Game +details** to Public in Steam's privacy settings, or the import finds your games +but no achievements. The import lists your owned games, then reads each game's +store page and your achievements, five games at a time. + +## GOG + +1. Click **Login**. GOG's sign-in page opens in your browser. +2. After signing in, the browser lands on a mostly blank + `embed.gog.com/on_login_success?...` page. Copy that whole address from the + address bar, paste it into the card, and click **Complete**. Pasting only + the `code=` value works too. +3. Click **Import GOG**. + +GOG gives Spotter a token that expires; Spotter renews it at startup, and +again when an import finds it expired, so you log in only once. GOG has no achievement data in the +API Spotter uses. + +## Epic Games + +The login is optional, and it decides what the import button does: + +- **Without a login**, the import scans this computer for installed games: the + Epic Games Launcher's manifests (on Windows, on macOS, or in the default Wine + prefix `~/.wine` on Linux) and Legendary's `installed.json`. The folders are + listed in + [api-integrations.md](../internals/api-integrations.md#epic-games). +- **With a login**, it imports every game the account owns. Click **Login**, + sign in on the page that opens, and copy what Epic shows afterwards: a short + JSON text that contains `authorizationCode`. Paste it into the card and click + **Complete**. The code alone, or an address with `?code=`, works too. + +Then click the import button. Spotter renews the Epic login at startup, like +GOG's. + +## Xbox + +Spotter reads Xbox data through [OpenXBL](https://xbl.io), a third-party +service, not through Microsoft directly. + +1. Sign in at [xbl.io](https://xbl.io) with your Microsoft account and copy + your API key. +2. Paste it into the card. The **Gamertag** field appears; it is only a label + for your profile, the import does not need it. +3. Click **Import Xbox**. + +The import reads your title history (Xbox One, Xbox Series, Xbox 360 and PC +games), each game's achievements, and store details. OpenXBL's free plan has an +hourly request limit, which a large library can reach. + +## PlayStation + +1. Sign in at [playstation.com](https://www.playstation.com) in your browser. +2. In the same browser, open + [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie). + It shows `{"npsso":"..."}`. Copy the 64-character value between the quotes. +3. Paste it into **NPSSO Token** and click **Import PlayStation**. + +The NPSSO token is a login cookie for your Sony account: treat it like a +password. It expires after about two months, and signing out of +playstation.com ends it sooner; paste a new one when the import says it is no +longer valid. Spotter imports every game in your trophy list with its trophy +counts, and adds playtime and covers for PS4 and PS5 games. It does not read +individual trophies. + +## Nintendo + +Nintendo offers no library API. Add your games with **Add Game** in the +sidebar. + +## After an import + +- Importing again updates playtime, achievements and store details, adds new + games, and keeps your status, rating and notes. +- Covers download after each import unless **Auto-download covers on + import** is off in Preferences. +- Every import writes `spotter_auto_backup.json` and a line per step to + `import.log`; their folders are listed in + [data-model.md](../internals/data-model.md#where-the-data-lives). diff --git a/docs/internals/api-integrations.md b/docs/internals/api-integrations.md index 48fee5f..1f7042b 100644 --- a/docs/internals/api-integrations.md +++ b/docs/internals/api-integrations.md @@ -1,164 +1,143 @@ -# API Integrations +# API integrations -Spotter imports games from 5 platforms. Each has a distinct authentication model and data source. +Spotter imports from five stores. Each importer is a blocking function that +runs on a worker thread and returns `Vec`; the merge into the library is +described in [architecture.md](architecture.md#import-merge). Only Steam's Web +API and OpenXBL are documented public APIs: the GOG, Epic and PlayStation +endpoints below are the ones their own clients use, and can change without +notice. The recorded responses in `tests/integration_tests.rs` pin down what +each parser expects. -## Steam - -**Module:** `steam.rs`, `steam_auth.rs` +Every importer appends its steps and counts to `import.log` through +`api_client::ImportLogger` (see +[data-model.md](data-model.md#where-the-data-lives)). -### Authentication +## HTTP client -1. **OpenID Login** — User clicks "Login with Steam", a local HTTP server starts on `localhost:29876`. Steam redirects back with the user's 64-bit Steam ID. -2. **API Key** — User provides their Steam Web API key from [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey). +All requests go through `ureq` agents built in `api_client.rs`: -### Endpoints +| | | +|---|---| +| connect timeout | 10 s | +| read timeout | 15 s for API calls, 30 s for downloads | +| `http_get_retry` | exponential backoff, 500 ms x 2^attempt; a 4xx fails at once, except 429 | +| `RateLimiter` | doubles its delay on each 429, up to 60 s, and eases back 25% per success | +| images | at most 10 MB, and the bytes must be a JPEG, PNG, WebP or GIF image | -| Endpoint | Purpose | -|----------|---------| -| `api.steampowered.com/IPlayerService/GetOwnedGames/v1/` | Owned games list (JSON) | -| `store.steampowered.com/app/{appid}/` | Game metadata (HTML scraping) | -| `steamcommunity.com/.../GetPlayerAchievements/` | Achievement data (HTML scraping) | +## Steam -### Import Flow +`steam/mod.rs` (import) and `steam/auth.rs` (login). -1. Fetch owned games via Web API (returns `appid`, `name`, `playtime_forever`) -2. Scrape store pages for metadata (genre, release date, cover, review %, description) -3. Scrape achievement pages per game -4. Concurrent scraping: 5 threads, 100ms delay between requests +**Login.** Steam OpenID 2.0. `steam_login` binds the first free port among +29876 to 29880 on `127.0.0.1`, opens `steamcommunity.com/openid/login` in the +browser with `http://localhost:/callback` as the return address, and +waits up to two minutes. The Steam ID is taken from `openid.claimed_id` only +after `steamcommunity.com` confirms the answer (`check_authentication`), so a +forged callback is rejected. The Web API key is typed by the user. -### Scraped Data +| Endpoint | Used for | +|---|---| +| `api.steampowered.com/IPlayerService/GetOwnedGames/v0001/` | owned games, names and playtime (key + Steam ID) | +| `store.steampowered.com/app//` | genre, release date, description, tags, review score (HTML) | +| `steamcommunity.com/stats//achievements` | the game's achievement list with icons (HTML) | +| `steamcommunity.com/profiles//stats/appid//achievements/` | the player's unlocks and dates (HTML, public profile only) | +| `shared.fastly.steamstatic.com/store_item_assets/steam/apps//header.jpg` | cover image | -From store HTML pages: -- **Genre:** `text` -- **Release date:** `
...
DATE
` -- **Cover:** `` -- **Review %:** Score extracted from review section -- **Tags:** From tag elements on the store page +Five worker threads share the game list, pause 500 ms between games, and retry +a store page twice. Games that already have store details or complete +achievements in the library are skipped, so a second import is much faster. +Achievement icons are downloaded during the import. ## GOG -**Module:** `gog.rs`, `gog_auth.rs` - -### Authentication - -OAuth 2.0 using well-known Galaxy client credentials (same for all GOG Galaxy clients): -- Client ID: `46899977096215655` -- Client secret: `9d85c43b1482497dbbce61f6e4aa173a433796eeae2571571f...` -- Redirect: `https://embed.gog.com/on_login_success` +`gog/mod.rs` (import) and `gog/auth.rs` (login). -Token refresh is automatic on 401 responses. +**Login.** OAuth 2.0 with the public GOG Galaxy client id. The browser opens +`auth.gog.com/auth`; after sign-in GOG redirects to +`embed.gog.com/on_login_success`, and the user pastes that address (or its +`code`). `auth.gog.com/token` exchanges the code for an access and a refresh +token, with the parameters in a form body so they stay out of URLs. The refresh +token renews the access token at startup and when an import fails. -### Endpoints +| Endpoint | Used for | +|---|---| +| `embed.gog.com/account/getFilteredProducts?mediaType=1&page=` | owned games, all pages (token) | +| `api.gog.com/products/?expand=description` | description, release date, genres and cover (public) | -| Endpoint | Purpose | -|----------|---------| -| `embed.gog.com/user/data/games` | Owned games list (auth required) | -| `api.gog.com/products/{id}` | Game metadata (public) | - -### Import Flow - -1. User logs in via OAuth (browser flow) -2. Fetch owned game IDs from user endpoint -3. Fetch metadata per game from public API -4. Data: title, cover (`logo2x`), description (`lead`), release date - -**Note:** GOG does not expose achievement data via API. +GOG's API has no achievement data for Spotter. ## Epic Games -**Module:** `epic.rs` - -### Authentication +`epic/`. Two independent imports, picked by whether the user is logged in. -None required. Reads local manifest files directly. +**Local scan** (`local.rs`), no login. Reads the Epic Games Launcher's `.item` +manifests and Legendary's `installed.json` from: -### Data Source - -Scans `.item` JSON manifests from the Epic Games Launcher: - -| OS | Path | -|----|------| -| Windows | `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\` | +| System | Folder | +|---|---| +| Windows | `%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests\` | | macOS | `/Users/Shared/Epic Games/Launcher/Data/Manifests/` | -| Linux (Heroic) | `~/.config/heroic/store_cache/` | -| Linux (Legendary) | `~/.config/legendary/installed.json` | -| Wine/Proton | `~/.wine/drive_c/ProgramData/Epic/...` | - -### Extracted Fields - -- `DisplayName` → title -- `CatalogItemId` → epic_id -- `AppName` → internal reference -- `AppCategories` → used to filter non-game content - -## Xbox Live - -**Module:** `xbox.rs` - -### Authentication - -Uses [OpenXBL](https://xbl.io) as a proxy API: -- Header: `X-Authorization: {api_key}` - -### Endpoint - -| Endpoint | Purpose | -|----------|---------| -| `xbl.io/api/v2/player/titleHistory` | Title history with stats | - -### Import Flow - -1. Fetch title history -2. Filter by device type: `XboxOne`, `XboxSeries`, `Xbox360`, `PC`, `Win32` -3. Extract: title, `displayImage` (cover), `minutesPlayed`, `lastTimePlayed`, achievements (current/total) - -### Filtering - -The `is_game()` heuristic filters out apps by checking for Xbox-specific device categories. - -## PlayStation Network - -**Module:** `playstation.rs` - -### Authentication - -Multi-step OAuth flow: -1. User provides **NPSSO token** (extracted from browser cookies at `store.playstation.com`) -2. Exchange NPSSO → authorization code via `ca.account.sony.com/api/v1/oauth/authorize` -3. Exchange code → access token via `ca.account.sony.com/api/v1/oauth/token` - -Uses hardcoded PSN Android app credentials for the OAuth client. - -### Endpoints - -| Endpoint | Purpose | -|----------|---------| -| `ca.account.sony.com/api/v1/oauth/authorize` | Authorization code | -| `ca.account.sony.com/api/v1/oauth/token` | Access token exchange | -| `m.np.playstation.com/api/graphql/v1/op?operationName=RetrieveTitleList` | Game library (GraphQL) | -| `m.np.playstation.com/api/trophy/v2/trophyTitles` | Trophy counts | - -### Import Flow - -1. Exchange NPSSO for OAuth token -2. Fetch game library via GraphQL -3. Fetch trophy counts (bronze/silver/gold/platinum → converted to unlocked/total) -4. Extract: title, playtime, last played, trophy data - -**Note:** No individual trophy details are available, only counts. +| Linux | `~/.config/legendary/` (`installed.json`), `~/.config/heroic/store_cache/`, and the launcher folder inside `~/.wine/drive_c/ProgramData/` | + +Only entries in the `games` category are kept. Store details then come from +`store-content-ipv4.ak.epicgames.com`. + +**Account import** (`auth.rs`, `online.rs`, `api.rs`, `graphql.rs`). OAuth with +the public Epic Games Launcher client id, the same one Legendary and Heroic +use. The browser opens `www.epicgames.com/id/login`, which ends on a JSON page +holding `authorizationCode`; the user pastes it, and +`account-public-service-prod.ol.epicgames.com` exchanges it for an access +token, a refresh token, the account id and the display name. + +| Endpoint | Used for | +|---|---| +| `library-service.live.use1a.on.epicgames.com/library/api/public/items` | owned items, paginated | +| `entitlement-public-service-prod08.ol.epicgames.com/entitlement/api/account//entitlements` | items missing from the library service | +| `launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/Windows` | the launcher's own asset list | +| `catalog-public-service-prod06.ol.epicgames.com/catalog/api/shared/namespace//bulk/items` | titles, categories, images | +| `store-content-ipv4.ak.epicgames.com/api/en-US/content/products/` | description, cover, tags, release date | +| `store.epicgames.com/graphql` | slugs, titles of unresolved items, ratings, achievement totals, fallback metadata | + +The three item lists are merged by namespace and catalog item id, DLC and +non-game items are filtered out, and items whose title cannot be resolved are +dropped. Epic does not grant the launcher client the scope for player +achievements, so only the total count is known. A 401 triggers one token +refresh and a retry. + +## Xbox + +`xbox.rs`, through [OpenXBL](https://xbl.io), a third-party proxy for Xbox Live. +Every request carries the user's key in the `X-Authorization` header. + +| Endpoint | Used for | +|---|---| +| `xbl.io/api/v2/account` | the user's XUID | +| `xbl.io/api/v2/player/titleHistory` | played titles with cover, playtime, last played and achievement counts, up to 50 pages | +| `xbl.io/api/v2/achievements/player//` | Xbox One, Series and PC achievements | +| `xbl.io/api/v2/achievements/x360//title/` | Xbox 360 achievements | +| `xbl.io/api/v2/marketplace/details` | descriptions and release dates for a batch of titles | + +`is_game` keeps titles played on `XboxOne`, `XboxSeries`, `Xbox360`, `PC` or +`Win32`. Achievements are saved per title under `platform_id = xbox:`. + +## PlayStation + +`playstation.rs`. + +**Login.** The user pastes an NPSSO token from +`ca.account.sony.com/api/v1/ssocookie`. Spotter exchanges it with the public +PlayStation app client id: `ca.account.sony.com/api/authz/v3/oauth/authorize` +returns an authorization code, and `.../oauth/token` an access token. The +access token is not stored; each import exchanges the NPSSO again. + +| Endpoint | Used for | +|---|---| +| `m.np.playstation.com/api/trophy/v1/users/me/trophyTitles` | every game with trophies and its earned and total trophy counts, paginated | +| `m.np.playstation.com/api/gamelist/v2/users/me/titles?categories=ps4_game,ps5_native_game` | playtime, last played and covers for PS4 and PS5 games | + +The trophy list is the game list; the played-games list only enriches it. +Individual trophies are not read. ## Nintendo -No API integration. Games are added manually through the "Add Game" form with `Platform::Nintendo` selected. - -## HTTP Client - -All API calls go through `api_client.rs`: - -``` -Connect timeout: 10s -Read timeout: 15s (API) / 30s (downloads) -Retry strategy: Exponential backoff (500ms × 2^attempt) - 4xx errors: fail immediately (no retry) - 5xx/network: retry up to N times -``` +No API. Games are added by hand with **Add Game**. diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index ee2b762..b23a4e0 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -1,144 +1,159 @@ # Architecture -Spotter is a cross-platform game library tracker built with **Rust** and the **Iced** GUI framework. It uses **SQLite** for local persistence and supports importing games from 5 platforms. +Spotter is one Rust binary: an [iced](https://iced.rs) 0.14 application on the +Elm pattern (state, messages, `update`, `view`), a SQLite database through +`rusqlite`, and one blocking `ureq` client per store. The look, the +Preferences page building blocks, the fonts, the en/fr shared labels and the +folder paths come from +[colony-ui](https://github.com/Project-Colony/Project-Colony-Resources/tree/main/crates/colony-ui), +the Project Colony design-system crate. -## Module Map +## Module map ``` -main.rs Entry point → spotter::run() -lib.rs Module tree, re-exports app::run -app.rs (1300+ lines) State machine: Spotter struct, Message enum, update(), view() -├── views/ -│ ├── library.rs Game list with search, filters, sort -│ ├── detail.rs Single game: metadata, achievements, rating, notes -│ ├── stats.rs Statistics dashboard with charts -│ ├── settings.rs Three-tab preferences (General / Appearances / Accessibility) -│ ├── profile.rs User profile, credentials, data export/import -│ ├── import.rs Platform import cards with step-by-step flows -│ ├── sidebar.rs Navigation + quick stats -│ └── add_game.rs Manual game entry form -├── models.rs Data structures: Game, Settings, Platform, Achievement, etc. -├── db.rs SQLite: schema, CRUD, playtime history, export/import -├── theme.rs Color constants, ViewTheme, style helpers -├── error.rs SpotterError enum (migration in progress) -├── api_client.rs HTTP agents, retry logic, URL encoding, browser open -├── images.rs Cover art + achievement icon download/caching -├── steam.rs Steam import (Web API + HTML scraping) -├── steam_auth.rs Steam OpenID login (localhost:29876) -├── gog.rs GOG import (OAuth API) -├── gog_auth.rs GOG OAuth 2.0 token exchange -├── epic.rs Epic Games local manifest scanner -├── xbox.rs Xbox Live import (OpenXBL API) -└── playstation.rs PSN import (OAuth + trophy API) +src/ +├── main.rs entry point: --version, then spotter::run() +├── lib.rs the whole module tree; the pub modules are what tests/ uses +├── app.rs Spotter (the state), Message, update(), view(), run() +├── handlers/ update() split by area: data and persistence, imports, +│ store logins, preferences +├── messages/ the message enums those areas add to Message +├── views/ one file per screen (library, game detail, statistics, +│ import, profile, add game, Preferences) plus the sidebar +├── models.rs Game, Platform, GameStatus, Achievement, UserProfile and +│ Spotter's own preferences +├── db.rs SQLite schema, migrations, queries, export and import +├── keyring.rs the seven secrets, in the OS keyring or the fallback file +├── api_client.rs HTTP agents, retry and rate limiting, the import log, +│ URL encoding, opening the browser +├── images.rs cover and achievement icon download and cache +├── theme.rs Spotter's widget styles over the colony-ui palette +├── error.rs SpotterError +├── steam/ mod.rs: owned games, store and achievement scraping +│ auth.rs: OpenID login on 127.0.0.1 +├── gog/ mod.rs: library and store metadata; auth.rs: OAuth code exchange +├── epic/ online.rs: account import; local.rs: installed-games scan; +│ auth.rs, api.rs, graphql.rs and helpers +├── xbox.rs OpenXBL import +└── playstation.rs NPSSO exchange, trophy titles, game list ``` -## Data Flow +`main.rs` declares no module of its own: it calls `spotter::run`, and +everything else hangs off `lib.rs`. Which store endpoints each importer calls is +in [api-integrations.md](api-integrations.md). + +## Data flow ``` -User Interaction - │ - ▼ - Message enum (100+ variants) - │ - ▼ - app::update() ──→ Task (async operations) - │ │ - ▼ ▼ - State mutation Background thread - (Spotter fields) (API calls, DB writes) - │ │ - ▼ ▼ - app::view() Result callback - (renders UI) (re-enters Message loop) +user input / keyboard + │ + ▼ + Message ──────► Spotter::update() ──► state change ──► Spotter::view() + ▲ │ + │ ▼ + │ Task (spawn_task / spawn_write) + │ │ + │ ▼ + └──── result ◄── worker thread: store requests, SQLite, files ``` -All state mutations pass through the `Message` enum in `app.rs`. Async operations (API calls, DB writes) are spawned as `Task` and return results via callback messages. - -## Screen Navigation +Every change goes through a `Message`. Views only read `&Spotter` and return +widgets; they never mutate it. Blocking work runs in `app::spawn_task`, which +moves the closure to a new thread and maps its `Result` back into a +message: +```rust +fn spawn_task(f: F, msg: impl FnOnce(Result) -> M + Send + 'static) -> Task ``` -┌──────────────┐ ┌────────────────────────────────────┐ -│ Sidebar │ │ Main Content Area │ -│ │ │ │ -│ Library ────┼───►│ Library (search, filter, game list)│ -│ + Add Game ─┼───►│ AddGame (form) │ -│ Statistics ─┼───►│ Statistics (charts, distributions) │ -│ Import ─────┼───►│ Import (platform cards) │ -│ Profile ────┼───►│ Profile (credentials, export) │ -│ │ │ │ -│ ── Quick ── │ │ GameDetail (from library click) │ -│ Playing: X │ │ Settings (from sidebar logo) │ -│ Completed: Y│ │ │ -│ Unplayed: Z │ └────────────────────────────────────┘ -│ │ -│ [Username] │ -└──────────────┘ -``` - -## Key Architectural Patterns -### Message-Driven Updates +Writes of saved data go through `Spotter::spawn_write` instead. If the startup +load failed, the app is holding defaults rather than the user's data, so +`spawn_write` refuses every write (preferences, profile, games, exports, +auto-backup) and reports it, and the files on disk stay as they were. -Every user interaction produces a `Message` variant. The `update()` function pattern-matches on it, mutates state, and optionally returns an async `Task`. +## Screens -```rust -// Example flow: rating a game -Message::SetGameRating(game_id, Some(8)) - → find game by ID, set rating - → spawn Task: save to DB - → on completion: Message::DataSaved(Result) - → show toast notification +``` +┌───────────────┐ ┌──────────────────────────────────────────┐ +│ [Spotter ⚙]───┼──►│ Preferences (replaces the content area; │ +│ │ │ same button or Close goes back) │ +│ Library ──────┼──►│ Library ──► Game detail │ +│ + Add Game ───┼──►│ Add Game │ +│ Statistics ───┼──►│ Statistics │ +│ Import ───────┼──►│ Import (one card per store) │ +│ Profile ──────┼──►│ Profile (name, keyring state, data paths, │ +│ │ │ export and import) │ +│ quick stats │ └──────────────────────────────────────────┘ +└───────────────┘ ``` -### Async via Thread Spawning +The sidebar stays on every screen. Keyboard: `Esc` cancels a pending delete, +`Ctrl+F` focuses the search, and `Ctrl+A` selects every game the library +currently shows (`Cmd` instead of `Ctrl` on macOS). -Iced uses a `Task` abstraction. Spotter wraps blocking operations in `std::thread::spawn`: +## Import merge -```rust -fn spawn_task(f: F, msg: fn(Result) -> M) -> Task -``` +An import returns a `Vec`, and `merge_imported_games` +(`handlers/data.rs`) folds it into the library. Each incoming game is matched, +in this order, on: -This runs `f` on a new thread and maps the result through `msg`. +1. its store id (`steam_appid`, `gog_id`, `epic_id`, `xbox_id`, `psn_id`); +2. its title and platform; +3. its title alone. -### Import Merge Logic +A matched game gets the incoming genre, description, release date, tags, cover +address and review score when the import has them, the achievement counts when +the import has any, and the larger of the two playtimes; store ids it lacked +are filled in. Status, rating, notes and last-played date are never touched. +Unmatched games are added, and arrive in the library one by one (the import +cascade) unless reduced motion is on. -When importing games from a platform, deduplication uses a multi-key index: +After an import Spotter saves the library, writes `spotter_auto_backup.json`, +and, unless turned off, downloads the missing covers. -1. **Platform ID match** (e.g., `steam_appid`) — most reliable -2. **Title + Platform match** — for games without IDs -3. **Title-only match** — last resort +## Look and preferences -Existing user data (ratings, notes, status) is always preserved. Only empty metadata fields are backfilled from imports. +Colours come from the active colony-ui theme (`colony_ui::Palette` roles, +`app_tint` for platform and status colours, `contrast_on` for text on a fill), +never from literals. Each view calls `Typography::current()` once and sizes +every text with `typo.sz(base)`, so the font size and the accessibility text +size both apply. Animations check `colony_ui::effects_enabled()`, which is false +under reduced motion. -### Theme System +The preferences file is Spotter's own struct with colony-ui's +`StandardPreferences` flattened into it. At startup Spotter loads it, calls +`StandardPreferences::apply()` and sets the locale; a change on the Preferences +page goes through `StandardPreferences::update()` and is saved at once. Text is +looked up by key in Spotter's en/fr tables merged with colony-ui's shared ones, +and a test keeps the two locales' key sets identical. -Two layers: -- **Static constants** (`theme.rs`): Used in style closures where `ViewTheme` isn't available -- **ViewTheme struct**: Computed per-render from `Settings`, provides theme-mode-aware colors +## Credentials -```rust -let vt = ViewTheme::from_settings(&app.settings); -// vt.bg_card, vt.text_muted, vt.border, etc. -``` +`keyring.rs` sits between the profile and the database. On load it reads the +seven secret fields from the keyring; on save it writes them there and keeps +their database columns empty. The first keyring failure in a session switches +it to the fallback file in `/credentials/` for the rest of the session, +and the Profile page shows a warning. It only deletes keyring entries it has +read or written this session, because a locked store can answer "no entry" +for a secret it does hold. ## Dependencies -| Crate | Version | Role | -|-------|---------|------| -| iced | 0.13 | GUI framework (with `image` feature) | -| rusqlite | 0.32 | SQLite (bundled, zero system deps) | -| ureq | 2 | Blocking HTTP client | -| serde / serde_json | 1 | JSON serialization | -| chrono | 0.4 | Date/time formatting | -| csv | 1 | CSV export | -| thiserror | 1 | Error derive macros | -| dirs | 5 | Platform-specific directories | - -## Build Profiles - -```toml -[profile.release] -lto = "thin" # Link-time optimization -strip = true # Strip debug symbols -opt-level = 3 # Maximum optimization -``` +| Crate | Role | +|---|---| +| `iced` (with `image`) | GUI | +| `colony-ui` | themes, typography, fonts, Preferences widgets, shared labels, folder paths | +| `rusqlite` (bundled) | SQLite, compiled in; no system library needed | +| `ureq` (with `json`) | blocking HTTP | +| `scraper` | Steam store and achievement pages | +| `keyring`, `keyring-core` | OS keyring, and its mock store in tests | +| `serde`, `serde_json`, `csv` | JSON and CSV | +| `chrono` | dates | +| `thiserror` | `SpotterError` | +| `winresource` (build) | the Windows icon and version resource | + +Every version is pinned exactly in `Cargo.toml`, and `Cargo.lock` is committed; +CI builds with `--locked`. + +The release profile uses thin LTO, one codegen unit, `opt-level = 3`, and strips +symbols. diff --git a/docs/internals/data-model.md b/docs/internals/data-model.md index 43ef826..21ee2ed 100644 --- a/docs/internals/data-model.md +++ b/docs/internals/data-model.md @@ -1,113 +1,143 @@ -# Data Model +# Data model -## Core Structures +## Where the data lives -### Game +Spotter uses the three standard Colony folders, through +`colony_ui::paths::{config_dir, data_dir, cache_dir}("Spotter")`, and keeps +them apart by sub-directory, because config and data are the same folder on +Windows and macOS: -The primary entity. 21 fields tracking a game across platforms. +| | Linux | Windows | macOS | +|---|---|---|---| +| config | `~/.config/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` | +| data | `~/.local/share/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` | +| cache | `~/.cache/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` | `~/Library/Caches/Colony/Spotter/` | -```rust -Game { - id: Option, // SQLite AUTOINCREMENT primary key - title: String, // Display name - platform: Platform, // Steam | Gog | Epic | PlayStation | Xbox | Nintendo - playtime_minutes: u32, // Total play time - achievements_unlocked: u32, // Player's unlocked count - achievements_total: u32, // Available achievements - status: GameStatus, // Playing | Completed | Unplayed | Dropped | Wishlist - rating: Option, // 0-10 user rating (None = unrated) - genre: String, // e.g. "Action RPG" - last_played: String, // "YYYY-MM-DD" or "—" if never - cover_url: String, // Remote URL for cover art - steam_appid: Option, // Steam App ID (e.g. 1245620 for Elden Ring) - gog_id: Option, // GOG product ID - epic_id: Option, // Epic catalog item ID - xbox_id: Option, // Xbox title ID - psn_id: Option, // PSN game ID - notes: String, // User notes (max 2000 chars) - description: String, // Store description - release_date: String, // "YYYY-MM-DD" - review_percent: Option, // Store review score (0-100) - tags: String, // Comma-separated tags -} +``` +/ +├── preferences/ +│ └── preferences.json every preference, see guide/configuration.md +└── credentials/ only when no OS keyring is usable (0600 on Unix) + +/ +├── library/ +│ └── spotter.db the SQLite database below +├── exports/ +│ ├── spotter_export.json written by Export JSON, read by Import JSON +│ ├── spotter_export.csv written by Export CSV +│ └── spotter_auto_backup.json rewritten after every import +└── logs/ + ├── import.log one line per import step + └── import.log.1 the previous log, rotated at 5 MB + +/ +├── covers/ one .jpg per game, named after its title +└── achievement_icons/ _.jpg and its grey variant ``` -**Builder pattern:** +The Profile page shows these paths with `colony_ui::paths::locate`, which does +not create anything. Deleting the cache folder costs nothing but the downloads. -```rust -GameBuilder::new("Elden Ring", Platform::Steam) - .playtime(12000) - .achievements(32, 42) - .status(GameStatus::Playing) - .rating(9) - .genre("Action RPG") - .steam_appid(1245620) - .build() -``` +Spotter 0.3 and older kept everything under `~/.config/Colony/Spotter/` +(`Application Support` on macOS, `AppData\Local` on Windows), with the database +in `data/` and the settings in the database. A newer version copies the +database, exports and logs from there on its first start, imports the old +settings row into `preferences.json`, writes a marker so the check runs once, +and never deletes the old folder. If the copy fails, it keeps using the old +location rather than start on an empty library. -### Platform +## Records -``` -Steam → color: rgb(0.4, 0.6, 0.9) (blue) -Gog → color: rgb(0.7, 0.5, 0.9) (purple) -Epic → color: rgb(0.9, 0.9, 0.3) (yellow) -PlayStation → color: rgb(0.2, 0.4, 0.9) (navy) -Xbox → color: rgb(0.2, 0.8, 0.2) (green) -Nintendo → color: rgb(0.9, 0.2, 0.2) (red) -``` +### Game -### GameStatus +The library entry, one row of the `games` table. -``` -Playing → color: rgb(0.2, 0.8, 0.4) (green) -Completed → color: rgb(0.3, 0.6, 1.0) (blue) -Unplayed → color: rgb(0.9, 0.7, 0.2) (gold) -Dropped → color: rgb(0.8, 0.3, 0.3) (red) -Wishlist → color: rgb(0.7, 0.5, 0.9) (purple) +```rust +Game { + id: Option, // SQLite primary key, None before the first save + title: String, + platform: Platform, // Steam | Gog | Epic | PlayStation | Xbox | Nintendo + playtime_minutes: u32, + achievements_unlocked: u32, + achievements_total: u32, + status: GameStatus, // Playing | Completed | Unplayed | Dropped | Wishlist + rating: Option, // 0-10, None = unrated + genre: String, + last_played: String, // "YYYY-MM-DD", empty if never + cover_url: String, // remote address; the file is in /covers + steam_appid: Option, + gog_id: Option, + epic_id: Option, + xbox_id: Option, + psn_id: Option, + notes: String, // the user's own notes + description: String, // store description + release_date: String, + review_percent: Option, // store review score, 0-100 + tags: String, // comma-separated +} ``` -### Achievement +The store ids are what an import matches on before it falls back to the title; +see [architecture.md](architecture.md#import-merge). -Per-game achievement data. Only available for Steam games currently. +### Achievement ```rust Achievement { - api_name: String, // Internal ID (e.g. "ACHIEVEMENT_001") - display_name: String, // "First Steps" - description: String, // "Complete the tutorial" - icon_url: String, // Colored icon URL (unlocked) - icon_gray_url: String, // Grayscale icon URL (locked) - unlocked: bool, // Player's unlock status - unlock_time: u64, // Unix timestamp (0 if locked) + api_name: String, // the store's internal id + display_name: String, + description: String, + icon_url: String, // unlocked icon + icon_gray_url: String, // locked icon + unlocked: bool, + unlock_time: u64, // Unix timestamp, 0 while locked } ``` -### UserProfile +Steam and Xbox imports store one row per achievement. PlayStation only fills +the game's unlocked and total counts. -Stores user identity and platform credentials. +### UserProfile ```rust UserProfile { - username: String, // Display name (default: "Player") - avatar_path: String, // Reserved for future use - steam_api_key: String, // From steamcommunity.com/dev/apikey - steam_id: String, // 64-bit Steam ID (from login) - gog_token: String, // OAuth access token - gog_refresh_token: String, // For automatic token renewal - xbox_api_key: String, // From xbl.io - xbox_gamertag: String, // Xbox username - psn_npsso: String, // PSN browser cookie token - member_since: String, // "YYYY-MM-DD" (auto-set on creation) + username: String, // display name, "Player" by default + avatar_path: String, // unused + steam_api_key: String, // secret + steam_id: String, // 64-bit Steam ID, from the Steam login + gog_token: String, // secret + gog_refresh_token: String, // secret + xbox_api_key: String, // secret, the OpenXBL key + xbox_gamertag: String, + xbox_xuid: String, // Xbox user id, read during an import + psn_npsso: String, // secret + epic_token: String, // secret + epic_refresh_token: String, // secret + epic_account_id: String, + epic_display_name: String, + member_since: String, // "YYYY-MM-DD", set on creation } ``` -### Settings +`keyring.rs` loads and saves the seven secret fields. They live in the OS +keyring under the service name `spotter`, one entry per field. When no keyring +is usable they go to `/credentials/` instead, and the Profile page says +so. The matching database columns stay empty. + +### Preferences -25 user-configurable preferences. See [CONFIGURATION.md](CONFIGURATION.md) for details. +`preferences.json` holds Spotter's own preferences and colony-ui's +`StandardPreferences`, flattened into one object. Every field is read on its +own, so one bad value only resets that field. The keys are listed in +[guide/configuration.md](../guide/configuration.md). -## Database Schema +## Database schema -SQLite with WAL mode and foreign keys enabled. +`spotter.db` is SQLite in WAL mode with foreign keys on and `secure_delete` +on, so an overwritten value does not linger in the file's free pages. +`db::open` creates the tables if they are missing and then runs the column +migrations in `db.rs`, once per session. ### games @@ -133,26 +163,29 @@ CREATE TABLE games ( description TEXT DEFAULT '', release_date TEXT DEFAULT '', review_percent INTEGER, - tags TEXT DEFAULT '' + tags TEXT DEFAULT '', + created_at TEXT DEFAULT CURRENT_TIMESTAMP ); ``` -**Indexes:** `platform`, `status`, `steam_appid`, `title`, `rating`, `last_played` +Indexes: `platform`, `status`, `title`, `rating`, `last_played`, and each store +id (`steam_appid`, `gog_id`, `epic_id`, `xbox_id`, `psn_id`). ### achievements ```sql CREATE TABLE achievements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - steam_appid INTEGER NOT NULL, - api_name TEXT NOT NULL, - display_name TEXT DEFAULT '', - description TEXT DEFAULT '', - icon_url TEXT DEFAULT '', - icon_gray_url TEXT DEFAULT '', - unlocked INTEGER DEFAULT 0, - unlock_time INTEGER DEFAULT 0, - UNIQUE(steam_appid, api_name) + id INTEGER PRIMARY KEY AUTOINCREMENT, + steam_appid INTEGER NOT NULL, + platform_id TEXT NOT NULL DEFAULT '', -- '' for Steam, 'xbox:' for Xbox + api_name TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + icon_url TEXT NOT NULL DEFAULT '', + icon_gray_url TEXT NOT NULL DEFAULT '', + unlocked INTEGER NOT NULL DEFAULT 0, + unlock_time INTEGER NOT NULL DEFAULT 0, + UNIQUE(steam_appid, platform_id, api_name) ); ``` @@ -161,46 +194,24 @@ CREATE TABLE achievements ( ```sql CREATE TABLE playtime_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id INTEGER NOT NULL REFERENCES games(id), - date TEXT NOT NULL, -- "YYYY-MM-DD" - minutes_played INTEGER DEFAULT 0 + game_id INTEGER NOT NULL, + date TEXT NOT NULL, -- "YYYY-MM-DD" + minutes_played INTEGER NOT NULL, + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE ); ``` -### user_profile +The Statistics screen charts the last 30 days of it. -Single-row table (id = 1) storing `UserProfile` fields as individual columns. +### user_profile -The credential columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, `xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) stay empty: `keyring.rs` keeps those secrets in the OS keyring. They hold a value, unencrypted, only when no keyring is usable, and `keyring.rs` moves it to the keyring on a later start. +A single row (`id = 1`) with one column per `UserProfile` field. The seven +secret columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, +`xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) are always +empty: the secrets are in the keyring, or in `<config>/credentials/` without +one. ### settings -Single-row table (id = 1) storing JSON-serialized `Settings` struct. - -```sql -CREATE TABLE settings ( - id INTEGER PRIMARY KEY, - data TEXT NOT NULL -- JSON blob -); -``` - -## Storage Locations - -| OS | Path | -|----|------| -| Linux | `~/.config/Colony/Spotter/` | -| macOS | `~/Library/Application Support/Colony/Spotter/` | -| Windows | `%LOCALAPPDATA%\Colony\Spotter\` | - -**Subdirectories:** - -``` -Colony/Spotter/ -├── data/ -│ └── spotter.db # SQLite database -├── cache/ -│ ├── covers/ # Game cover art (JPEG) -│ └── achievement_icons/ # Achievement icons (colored + grayscale) -└── exports/ - └── spotter_export.json # JSON backup file -``` +A single row (`id = 1`) with a JSON `data` column. Only Spotter 0.3 and older +wrote it; a newer version reads it once to seed `preferences.json` on upgrade. diff --git a/docs/internals/development.md b/docs/internals/development.md index 46b6d6d..f31346a 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -1,142 +1,102 @@ -# Development Guide +# Development ## Prerequisites -- **Rust** 1.90+ (2021 edition) -- SQLite is bundled via `rusqlite`, nothing to install for it -- On Linux, the windowing and keyring development packages (Debian/Ubuntu names): - `pkg-config libxkbcommon-dev libxkbcommon-x11-dev libxcb1-dev libxcb-xkb-dev libwayland-dev libdbus-1-dev libsecret-1-dev` +- **Rust 1.90** or newer (`rust-version` in `Cargo.toml`; CI builds and tests on + exactly 1.90 too). +- Nothing for SQLite: `rusqlite` compiles it in. +- On Linux, the windowing and keyring development packages. With Debian or + Ubuntu names, as CI installs them: -## Build + ```bash + sudo apt-get install pkg-config libxkbcommon-dev libxkbcommon-x11-dev \ + libxcb1-dev libxcb-xkb-dev libwayland-dev libdbus-1-dev libsecret-1-dev + ``` -```bash -# Debug build -cargo build - -# Release build (optimized, stripped) -cargo build --release -``` - -The release binary is at `target/release/spotter`. - -## Run +## Build and run ```bash -cargo run +cargo build --locked # debug build +cargo run --locked # opens the window +cargo run --locked -- --version # prints "spotter <version>" and exits +cargo build --locked --release # optimised and stripped: target/release/spotter ``` -On first launch, Spotter creates its data directories and an empty SQLite database. +The first start creates Spotter's folders and an empty database; the folders +are listed in [data-model.md](data-model.md#where-the-data-lives). A debug +build uses the same folders as an installed Spotter, so it opens your real +library: back it up first, or work under a test user. ## Test ```bash -# Run all tests -cargo test +cargo test --locked ``` -Tests are in `tests/unit_tests.rs` and `tests/integration_tests.rs`. Each DB test creates an isolated temporary directory with its own database via `db::open_at()`, so tests can run in parallel. +The tests run in parallel. Each database test opens its own SQLite file in a +fresh temporary directory through `db::open_at`, and the keyring tests use +`keyring-core`'s mock store, so nothing touches your library or your keyring. -### Test Categories +| Where | What it covers | +|---|---| +| `tests/unit_tests.rs` | models and enums, preferences parsing and versioning, the database (CRUD, batch save, achievements, playtime history, profile, export and import), CSV and JSON export, errors | +| `tests/integration_tests.rs` | each importer's parser against recorded Steam, PlayStation, Xbox, GOG and Epic responses, image validation, the rate limiter, the import log, URL encoding | +| `#[cfg(test)]` modules in `src/` | Steam HTML scraping, multi-byte title and description cuts, keyring moves, the write refusal after a failed load, the folders and their migration, the en/fr key parity, the window icon | -| Category | Count | What's tested | -|----------|-------|---------------| -| Playtime display | 3 | Format hours/minutes strings | -| Models | 20+ | Enum roundtrips, Display impls, color values, settings serialization | -| Database | 8 | CRUD, batch save, achievements, playtime history, export/import, profile, settings | -| Theme | 2 | ViewTheme high contrast, ThemeMode background differences | -| API client | 3 | URL encode/decode, roundtrip | -| Error | 2 | Display format, String conversion | +Not covered by automated tests: live store requests (the importers are only +tested against recorded responses), the login flows end to end, the import +merge in `handlers/data.rs`, and rendering. +A change to a view, the Preferences page or the French layout needs a look on a +real desktop at both ends of the text scale (0.72x and 1.68x). -### Coverage Gaps +## Before a pull request -The following areas lack test coverage: -- HTML scraping (Steam store pages) -- Import merge/deduplication logic -- OAuth flows (Steam, GOG, PSN) -- View rendering (Iced widget testing is limited) -- API client retry behavior - -## Project Structure - -``` -src/ -├── main.rs # Entry point -├── lib.rs # Module tree; public API for integration tests -├── app.rs # Core state machine (~1300 lines) -├── models.rs # All data structures (~800 lines) -├── db.rs # SQLite operations -├── theme.rs # Colors and style helpers -├── error.rs # SpotterError enum -├── api_client.rs # HTTP utilities -├── images.rs # Cover/icon download + caching -├── steam.rs # Steam import -├── steam_auth.rs # Steam OpenID -├── gog.rs # GOG import -├── gog_auth.rs # GOG OAuth -├── epic.rs # Epic local scanner -├── xbox.rs # Xbox OpenXBL import -├── playstation.rs # PSN import -└── views/ - ├── mod.rs # Module declarations - ├── library.rs # Game list view - ├── detail.rs # Game detail view - ├── stats.rs # Statistics dashboard - ├── settings.rs # Preferences view - ├── profile.rs # Profile & data management - ├── import.rs # Import flows - ├── sidebar.rs # Navigation sidebar - └── add_game.rs # Manual game entry - -tests/ -└── unit_tests.rs # All tests (40 tests) - -docs/ -├── ARCHITECTURE.md # Architecture overview -├── DATA_MODEL.md # Data model & DB schema -├── API_INTEGRATIONS.md # External API documentation -├── CONFIGURATION.md # Settings & theming -└── DEVELOPMENT.md # This file -``` +CI runs on Linux, Windows and macOS, and again on Rust 1.90: -## Key Conventions - -### Error Handling - -The codebase currently uses `Result<T, String>` for most operations. A typed `SpotterError` enum exists in `error.rs` for gradual migration. New code should prefer `SpotterError` where possible. - -### State Mutations - -All state changes go through the `Message` enum in `app.rs`. Never mutate `Spotter` fields directly from views. Views are pure rendering functions. - -### Async Operations - -Use `spawn_task()` for background work: - -```rust -fn spawn_task<T, F, M>(f: F, msg: fn(Result<T, String>) -> M) -> Task<M> +```bash +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo build --locked +cargo test --locked +cargo audit ``` -This spawns a thread and maps the result through a `Message` variant. +On Windows it also checks that the executable's version resource says +`ProductName` Spotter and the crate's version, which code signing requires. +Pull request titles are conventional commits (`feat:`, `fix:`, `docs:` ...), +because they become the squash commit and the changelog entry. Releases are cut +by release-please; see the +[code signing policy](../../README.md#code-signing-policy) for what a release +carries. -### Database Access +## Conventions -Always open a fresh connection per operation via `db::open()`. The connection uses WAL mode for concurrent reads. +**State changes.** Everything goes through a `Message` and `Spotter::update`; +views only read `&Spotter`. Add a variant to the area's message enum in +`messages/` and handle it in the matching file in `handlers/`. -### Styling +**Background work.** Wrap blocking work in `app::spawn_task`, and anything that +writes saved data in `Spotter::spawn_write`, which refuses the write after a +failed startup load. -Use `ViewTheme::from_settings()` at the top of each view function. For style closures (button/container styles), use the static constants from `theme.rs`. +**Database.** Open a connection per operation with `db::open()`; WAL mode lets +reads run alongside a write. A new column goes into both `init_tables` and the +`migrate` list in `db.rs`, so existing databases get it. -## Environment Variables +**Secrets.** A new credential is a `UserProfile` field listed in +`keyring::secrets`, so it goes to the keyring and never into the database. -| Variable | Purpose | -|----------|---------| -| `XDG_CONFIG_HOME` | Override config directory (Linux). | +**Look.** Colours come from colony-ui's palette roles and text sizes from +`Typography::current()` and `typo.sz(base)`; no colour literals and no raw +`.size(N)`. Gate animations on `colony_ui::effects_enabled()`. -## Common Warnings +**Text.** Every user-visible string is a key, present in both the English and +the French table (a test fails otherwise). French uses the vous form and a +space before `:`, `;`, `!` and `?`. Store and product names stay untranslated. -The following `dead_code` warnings are expected and can be ignored: +**Files.** Paths come from `colony_ui::paths` (`config_dir`, `data_dir`, +`cache_dir`), never from `dirs` directly; use `paths::locate` to show a path +without creating it. -- `save_single_game`: utility function reserved for future single-game save -- `export_games_json` / `export_games_csv`: called via message dispatch, not detected by static analysis -- `error::Result`: alias for future `SpotterError` migration -- Various `GameBuilder` methods: available for test convenience +**Errors.** Most functions still return `Result<T, String>`; the database layer +uses `SpotterError` from `error.rs`, and new code should prefer it. From ef61bfeedb1408bcfa34cd6376b991694058ecef Mon Sep 17 00:00:00 2001 From: MotherSphere <linhajahad@gmail.com> Date: Mon, 12 Oct 2026 13:10:51 +0200 Subject: [PATCH 3/6] docs: name the credentials fallback file --- README.md | 2 +- SECURITY.md | 6 +++--- docs/guide/configuration.md | 6 +++--- docs/internals/architecture.md | 4 ++-- docs/internals/data-model.md | 7 ++++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4a11dc2..e6779c2 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ use a feature that needs it. |---|---|---| | Your library | stored | `library/spotter.db` in Spotter's data folder: games, playtime history, achievements, your profile name and connected account ids | | Preferences | stored | `preferences/preferences.json` in Spotter's config folder | -| Credentials | stored | your Steam API key, OpenXBL key, PlayStation NPSSO token and the GOG and Epic login tokens, in the operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux such as GNOME Keyring or KWallet). Without a usable keyring they are written unencrypted to `credentials/` in the config folder (owner-only on Linux and macOS), and the Profile page says so | +| Credentials | stored | your Steam API key, OpenXBL key, PlayStation NPSSO token and the GOG and Epic login tokens, in the operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux such as GNOME Keyring or KWallet). Without a usable keyring they are written unencrypted to `credentials/tokens.json` in the config folder (owner-only on Linux and macOS), and the Profile page says so | | Exports and backups | stored | `exports/` in the data folder: `spotter_export.json` and `spotter_export.csv` when you export, `spotter_auto_backup.json` after every import | | Import log | stored | `logs/import.log` in the data folder (rotated to `import.log.1` at 5 MB): import steps, game counts and errors, plus your Xbox user id | | Covers and achievement icons | stored | `covers/` and `achievement_icons/` in Spotter's cache folder; safe to delete, they are downloaded again | diff --git a/SECURITY.md b/SECURITY.md index b9fb084..a7e3ef6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,9 +32,9 @@ and you are credited in the release notes if you want to be. Reports of particular interest: - **Credentials**: a store token or API key written anywhere other than the - operating system's keyring while a keyring is usable, a fallback file in - `credentials/` readable by other users, or a secret that reaches a log, an - export, a URL or the wrong host. + operating system's keyring while a keyring is usable, a fallback + `credentials/tokens.json` readable by other users, or a secret that reaches + a log, an export, a URL or the wrong host. - **The Steam login listener**: Spotter accepts the OpenID answer on `127.0.0.1` (ports 29876 to 29880) and confirms it with `steamcommunity.com` before trusting the Steam ID. A way to make it accept a forged answer, or to diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1c96ac6..d27041b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -24,9 +24,9 @@ config folder: You can edit it while Spotter is closed. A missing field takes its default, and a value Spotter does not understand (for example one written by a newer version) falls back to its default while every other field keeps its value. -If the file cannot be read at all, Spotter starts on defaults, says so, and -refuses to save anything for the rest of the session, so the file on disk stays -as it was. +If the file is not valid JSON or cannot be read, Spotter starts on defaults, +says so, and refuses to save anything for the rest of the session, so the file +on disk stays as it was. The file also holds a little state that is not a preference: your favourite games and, when **Remember filters** is on, the last status and platform diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index b23a4e0..3071bb9 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -132,8 +132,8 @@ and a test keeps the two locales' key sets identical. `keyring.rs` sits between the profile and the database. On load it reads the seven secret fields from the keyring; on save it writes them there and keeps their database columns empty. The first keyring failure in a session switches -it to the fallback file in `<config>/credentials/` for the rest of the session, -and the Profile page shows a warning. It only deletes keyring entries it has +it to the fallback file `<config>/credentials/tokens.json` for the rest of the +session, and the Profile page shows a warning. It only deletes keyring entries it has read or written this session, because a locked store can answer "no entry" for a secret it does hold. diff --git a/docs/internals/data-model.md b/docs/internals/data-model.md index 21ee2ed..f97b944 100644 --- a/docs/internals/data-model.md +++ b/docs/internals/data-model.md @@ -17,7 +17,8 @@ Windows and macOS: <config>/ ├── preferences/ │ └── preferences.json every preference, see guide/configuration.md -└── credentials/ only when no OS keyring is usable (0600 on Unix) +└── credentials/ + └── tokens.json only when no OS keyring is usable (0600 on Unix) <data>/ ├── library/ @@ -122,8 +123,8 @@ UserProfile { `keyring.rs` loads and saves the seven secret fields. They live in the OS keyring under the service name `spotter`, one entry per field. When no keyring -is usable they go to `<config>/credentials/` instead, and the Profile page says -so. The matching database columns stay empty. +is usable they go to `<config>/credentials/tokens.json` instead, and the +Profile page says so. The matching database columns stay empty. ### Preferences From 8f441ab1b41d186117960d329d9ff2b532ae74af Mon Sep 17 00:00:00 2001 From: MotherSphere <linhajahad@gmail.com> Date: Mon, 12 Oct 2026 13:34:52 +0200 Subject: [PATCH 4/6] docs: say where tokens and files end up when the keyring or the 0.3 copy fails --- README.md | 6 +++++- docs/guide/configuration.md | 7 ++++++- docs/internals/architecture.md | 6 +++++- docs/internals/data-model.md | 19 +++++++++++++------ docs/internals/development.md | 7 ++++--- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e6779c2..8ee30d4 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ use a feature that needs it. |---|---|---| | Your library | stored | `library/spotter.db` in Spotter's data folder: games, playtime history, achievements, your profile name and connected account ids | | Preferences | stored | `preferences/preferences.json` in Spotter's config folder | -| Credentials | stored | your Steam API key, OpenXBL key, PlayStation NPSSO token and the GOG and Epic login tokens, in the operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux such as GNOME Keyring or KWallet). Without a usable keyring they are written unencrypted to `credentials/tokens.json` in the config folder (owner-only on Linux and macOS), and the Profile page says so | +| Credentials | stored | your Steam API key, OpenXBL key, PlayStation NPSSO token and the GOG and Epic login tokens, in the operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux such as GNOME Keyring or KWallet). Without a usable keyring they are written unencrypted to `credentials/tokens.json` in the config folder (owner-only on Linux and macOS), or, if that file cannot be written, kept unencrypted in the library database until a later start can move them; the Profile page says when the keyring is not used | | Exports and backups | stored | `exports/` in the data folder: `spotter_export.json` and `spotter_export.csv` when you export, `spotter_auto_backup.json` after every import | | Import log | stored | `logs/import.log` in the data folder (rotated to `import.log.1` at 5 MB): import steps, game counts and errors, plus your Xbox user id | | Covers and achievement icons | stored | `covers/` and `achievement_icons/` in Spotter's cache folder; safe to delete, they are downloaded again | @@ -203,6 +203,10 @@ use a feature that needs it. The config, data and cache folders are `Colony/Spotter/` under your system's config, data and cache directories; the full table per operating system is in [docs/internals/data-model.md](docs/internals/data-model.md#where-the-data-lives). +If copying the files of Spotter 0.3 or older fails, Spotter keeps using the old +files in the config folder (`data/spotter.db`, `exports/`, `logs/`). The 0.3 +folder is never deleted for you: on a system without a usable keyring, its old +database still holds your tokens unencrypted until you delete it. Spotter connects to no server except the ones in this table, and only for the action named there. Links such as a game's store page open in your browser; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d27041b..fbb6eda 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -85,6 +85,11 @@ settings inside the database. On the first start of a newer version, Spotter: and Midnight themes and the six old accents to the nearest shared theme and accent, and the old UI scale to the text size; - leaves the old folder untouched, so nothing is lost if something goes wrong. - Delete it yourself once your library is all there. + Delete it yourself once your library is all there. On a system without a + usable keyring, the old database in that folder still holds your platform + tokens unencrypted until you do. + +If the copy fails, Spotter keeps using the old files where they are and tries +again on the next start. Covers and achievement icons are not copied: they download again. diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 3071bb9..961a606 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -133,7 +133,11 @@ and a test keeps the two locales' key sets identical. seven secret fields from the keyring; on save it writes them there and keeps their database columns empty. The first keyring failure in a session switches it to the fallback file `<config>/credentials/tokens.json` for the rest of the -session, and the Profile page shows a warning. It only deletes keyring entries it has +session, and the Profile page shows a warning. If that file cannot be written +either, the secrets stay in their database columns, unencrypted, and the next +start moves them out again. On load, secrets still in the database (Spotter +0.3 and older) move to the keyring, or to the file without one, and secrets in +the file move to the keyring once it works again. It only deletes keyring entries it has read or written this session, because a locked store can answer "no entry" for a secret it does hold. diff --git a/docs/internals/data-model.md b/docs/internals/data-model.md index f97b944..ff95050 100644 --- a/docs/internals/data-model.md +++ b/docs/internals/data-model.md @@ -36,8 +36,9 @@ Windows and macOS: └── achievement_icons/ <appid>_<name>.jpg and its grey variant ``` -The Profile page shows these paths with `colony_ui::paths::locate`, which does -not create anything. Deleting the cache folder costs nothing but the downloads. +`paths::layout()` in `src/paths.rs` resolves these paths and creates the +folders once, at startup; the Profile page shows the paths it resolved. +Deleting the cache folder costs nothing but the downloads. Spotter 0.3 and older kept everything under `~/.config/Colony/Spotter/` (`Application Support` on macOS, `AppData\Local` on Windows), with the database @@ -45,7 +46,11 @@ in `data/` and the settings in the database. A newer version copies the database, exports and logs from there on its first start, imports the old settings row into `preferences.json`, writes a marker so the check runs once, and never deletes the old folder. If the copy fails, it keeps using the old -location rather than start on an empty library. +files where they are (`<config>/data/spotter.db`, `<config>/exports/`, +`<config>/logs/`) rather than start on an empty library, and tries the copy +again on the next start. On a system without a usable keyring, the old +database left in `<config>/data/` can still hold the platform tokens +unencrypted. ## Records @@ -208,9 +213,11 @@ The Statistics screen charts the last 30 days of it. A single row (`id = 1`) with one column per `UserProfile` field. The seven secret columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, -`xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) are always -empty: the secrets are in the keyring, or in `<config>/credentials/` without -one. +`xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) are empty: +the secrets are in the keyring, or in `<config>/credentials/tokens.json` +without one. A secret stays in its column, unencrypted, only when neither the +keyring nor that file could take it; `keyring.rs` moves it out on the next +start. ### settings diff --git a/docs/internals/development.md b/docs/internals/development.md index f31346a..40f72ef 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -94,9 +94,10 @@ reads run alongside a write. A new column goes into both `init_tables` and the the French table (a test fails otherwise). French uses the vous form and a space before `:`, `;`, `!` and `?`. Store and product names stay untranslated. -**Files.** Paths come from `colony_ui::paths` (`config_dir`, `data_dir`, -`cache_dir`), never from `dirs` directly; use `paths::locate` to show a path -without creating it. +**Files.** Spotter's own paths come from `paths::layout()` in `src/paths.rs`, +built on `colony_ui::paths` (`config_dir`, `data_dir`, `cache_dir`); never +build them from `dirs` directly. The layout is resolved, and its folders +created, once at startup. **Errors.** Most functions still return `Result<T, String>`; the database layer uses `SpotterError` from `error.rs`, and new code should prefer it. From 09626b026a3ed6cb0467e7d969e0ef99fd378fd7 Mon Sep 17 00:00:00 2001 From: MotherSphere <linhajahad@gmail.com> Date: Mon, 12 Oct 2026 19:10:28 +0200 Subject: [PATCH 5/6] docs: match the pages to the Preferences labels, text helpers and keys of the design-system code --- docs/guide/configuration.md | 67 ++++++++++++++++++---------------- docs/guide/platform-setup.md | 37 ++++++++++--------- docs/internals/architecture.md | 35 ++++++++++++------ docs/internals/data-model.md | 5 ++- docs/internals/development.md | 34 ++++++++++++----- 5 files changed, 105 insertions(+), 73 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fbb6eda..8c238a3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,9 +1,9 @@ # Configuration Every preference is set on the **Preferences** page. Open it with the -**Spotter** button at the top of the sidebar; the same button, or **Close**, -takes you back to the screen you were on. A change applies at once and is -saved at once: there is no Save button. +**Spotter** button at the top of the sidebar; the same button, **Close** or +`Esc` takes you back to the screen you were on. A change applies at once and +is saved at once: there is no Save button. The page lists the categories every Colony program shares, **General**, **Appearance** and **Accessibility**, then Spotter's own, **Library** and @@ -43,36 +43,34 @@ and are stored under the same keys. | Appearance, Colors & accents | accent | `selected_accent` | none | one of the eight shared accent colours; none keeps the theme's own | | Appearance, Colors & accents | Auto accent from background | `auto_accent` | off | uses the theme's own accent instead of the one picked | | Appearance, Typography | Text size | `font_size` | default | small, default or large | -| Appearance, Effects | Animations | `animations` | on | animated transitions and fades | +| Appearance, Effects | Animations | `animations` | on | animated transitions and fades; off, imported games appear at once instead of one by one | | Accessibility, Vision | High contrast | `high_contrast` | off | stronger text and border contrast | | Accessibility, Vision | Dyslexia-friendly font | `dyslexia_font` | off | switches the interface to OpenDyslexic | -| Accessibility, Motion | Reduce motion | `reduce_motion` | off | turns every animation off, whatever **Animations** says, and imported games appear at once instead of one by one | -| Accessibility, Reading | Text size | `text_size_a11y` | default | small, default, large or extra large, multiplied with the Typography size: text ranges from 0.72x to 1.68x | - -**Language** is in General: English or French. On the first start Spotter -follows the system language, and falls back to English. +| Accessibility, Motion | Reduce motion | `reduce_motion` | off | turns every animation off, whatever **Animations** says | +| Accessibility, Reading | Text size | `text_size_a11y` | default | small, default, large or extra large, multiplied with the Typography size: text ranges from 0.7225x to 1.68x | ## Spotter's own preferences -| Preference | Key | Default | What it changes | -|---|---|---|---| -| Default status for new games | `default_status` | Unplayed | the status pre-selected in **Add Game** | -| Default platform | `default_platform` | Steam | the platform pre-selected in **Add Game** | -| Confirm before deleting | `confirm_before_delete` | on | asks before a game is removed | -| Start screen | `start_screen` | Library | Library, Statistics, Import or Profile | -| Show notifications | `notifications_enabled` | on | success messages; errors always show | -| Toast duration | `toast_duration` | Normal | how long a message stays: Short (2 s), Normal (4 s), Long (8 s) | -| Date format | `date_format` | YYYY-MM-DD | or DD/MM/YYYY, or MM/DD/YYYY | -| Default sort order | `default_sort_order` | Title A-Z | the library's sort when Spotter starts | -| Remember filters | `remember_filters` | on | keeps the status and platform filters between sessions | -| Show descriptions in library | `show_game_descriptions` | off | store descriptions on the library cards | -| Achievements display | `achievements_display` | Full | the full list, a summary, or nothing on a game's page | -| Compact library list | `compact_list` | off | less space between games | -| Show cover images in library | `show_covers_in_list` | on | cover thumbnails next to the titles | -| Sidebar width | `sidebar_width` | 220 | in pixels, 160 to 400 | -| Auto-download covers on import | `download_covers_auto` | on | downloads covers after every import | -| Show status labels | `show_status_labels` | on | a text label next to each status colour, so status does not rely on colour alone | -| Large click targets | `large_click_targets` | off | more padding on buttons | +| Where | Preference | Key | Default | What it changes | +|---|---|---|---|---| +| General, Language | Interface language | `language` | none (System language) | English or Français. The system language is read from `LC_ALL`, `LC_MESSAGES`, then `LANG`, which Windows and macOS usually leave unset; anything but French means English | +| General, Startup | Start screen | `start_screen` | Library | Library, Statistics, Import or Profile | +| General, Notifications | Show notifications | `notifications_enabled` | on | success messages; errors always show | +| General, Notifications | Duration | `toast_duration` | Normal | how long a message stays: Short (2 s), Normal (4 s), Long (8 s) | +| General, Dates | Date format | `date_format` | YYYY-MM-DD | or DD/MM/YYYY, or MM/DD/YYYY | +| Appearance, Layout | Sidebar width | `sidebar_width` | 220 | Narrow (180), Normal (220) or Wide (280) pixels; the file accepts any width, drawn between 160 and 400 | +| Appearance, Layout | Compact library list | `compact_list` | off | less space between games | +| Appearance, Layout | Covers in the library | `show_covers_in_list` | on | cover thumbnails next to the titles | +| Accessibility, Color cues | Show status labels | `show_status_labels` | on | a text label next to each status colour, so status does not rely on colour alone | +| Accessibility, Navigation | Large click targets | `large_click_targets` | off | more padding on buttons and chips | +| Library, New games | Default status | `default_status` | Unplayed | the status pre-selected on the **Add game** page | +| Library, New games | Default platform | `default_platform` | Steam | the platform pre-selected on the **Add game** page | +| Library, Sorting and filters | Sort order | `default_sort_order` | A-Z | the library's sort when Spotter starts | +| Library, Sorting and filters | Remember filters | `remember_filters` | on | keeps the status and platform filters between sessions | +| Library, Game details | Descriptions in the library | `show_game_descriptions` | off | store descriptions on the library cards | +| Library, Game details | Achievements | `achievements_display` | Full list | the full list, a summary, or nothing on a game's page | +| Library, Deleting | Confirm before deleting | `confirm_before_delete` | on | asks before a game is removed | +| Imports, Covers | Download covers after an import | `download_covers_auto` | on | downloads the missing covers after every import | ## Upgrading from Spotter 0.3 or older @@ -81,14 +79,21 @@ settings inside the database. On the first start of a newer version, Spotter: - copies the database, the exports and the import logs to the folders listed in [data-model.md](../internals/data-model.md#where-the-data-lives); -- writes the old settings to `preferences.json`, mapping the old Dark, Darker - and Midnight themes and the six old accents to the nearest shared theme and - accent, and the old UI scale to the text size; +- writes the old settings to `preferences.json`, with the old look mapped to + the nearest shared one; - leaves the old folder untouched, so nothing is lost if something goes wrong. Delete it yourself once your library is all there. On a system without a usable keyring, the old database in that folder still holds your platform tokens unencrypted until you do. +| Old setting | Becomes | +|---|---| +| `theme_mode` Dark, Darker, Midnight | the Tokyo Night night, Material deep ocean and Modus vivendi themes | +| `accent_color` Blue or Cyan, Purple, Green, Gold, Red | the `blue`, `violet`, `green`, `amber` and `red` accents | +| `ui_scale` Small, Normal, Large | the Accessibility text size small, default, large | + +The old scale enlarged the whole window; the text sizes enlarge the text only. + If the copy fails, Spotter keeps using the old files where they are and tries again on the next start. diff --git a/docs/guide/platform-setup.md b/docs/guide/platform-setup.md index 43dfa2a..3be319b 100644 --- a/docs/guide/platform-setup.md +++ b/docs/guide/platform-setup.md @@ -1,27 +1,27 @@ # Platform setup Everything below happens on the **Import** screen: each store has a card with -its steps, a status pill (Setup required, Ready, Importing) and an import -button. What you enter there is kept in the operating system's keyring, see +its numbered steps, a status badge (Setup needed, Ready and so on) and an +import button. What you enter there is kept in the operating system's keyring, see [Privacy](../../README.md#privacy). | Store | What Spotter needs | Where to get it | |---|---|---| -| Steam | your Steam ID and a Steam Web API key | the **Login** button, then [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | -| GOG | a GOG login | the **Login** button, then paste the address GOG redirects you to | -| Epic Games | nothing, or an Epic login for the whole library | the **Login** button, then paste the answer Epic shows | +| Steam | your Steam ID and a Steam Web API key | the **Log in** button, then [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | +| GOG | a GOG login | the **Log in** button, then paste the address GOG redirects you to | +| Epic Games | nothing, or an Epic login for the whole library | the **Log in** button, then paste the answer Epic shows | | Xbox | an OpenXBL API key | [xbl.io](https://xbl.io) (free account) | | PlayStation | an NPSSO token | [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie) while signed in to PlayStation | -| Nintendo | nothing | no import: add games with **Add Game** | +| Nintendo | nothing | no import: add games on the **Add game** page | ## Steam -1. Click **Login**. Steam's sign-in page opens in your browser. Sign in and +1. Click **Log in**. Steam's sign-in page opens in your browser. Sign in and approve; Steam sends you back to a page served by Spotter on `127.0.0.1`, and the card shows your Steam ID. Spotter waits two minutes for the answer. 2. Create a Web API key at [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) (any - domain name works) and paste it into **Steam Web API key**. + domain name works) and paste it into the **API key** field. 3. Click **Import Steam**. Achievements are read from your Steam community profile, so set **Game @@ -31,10 +31,10 @@ store page and your achievements, five games at a time. ## GOG -1. Click **Login**. GOG's sign-in page opens in your browser. +1. Click **Log in**. GOG's sign-in page opens in your browser. 2. After signing in, the browser lands on a mostly blank `embed.gog.com/on_login_success?...` page. Copy that whole address from the - address bar, paste it into the card, and click **Complete**. Pasting only + address bar, paste it into the card, and click **Finish**. Pasting only the `code=` value works too. 3. Click **Import GOG**. @@ -51,12 +51,13 @@ The login is optional, and it decides what the import button does: prefix `~/.wine` on Linux) and Legendary's `installed.json`. The folders are listed in [api-integrations.md](../internals/api-integrations.md#epic-games). -- **With a login**, it imports every game the account owns. Click **Login**, +- **With a login**, it imports every game the account owns. Click **Log in**, sign in on the page that opens, and copy what Epic shows afterwards: a short JSON text that contains `authorizationCode`. Paste it into the card and click - **Complete**. The code alone, or an address with `?code=`, works too. + **Finish**. The code alone, or an address with `?code=`, works too. -Then click the import button. Spotter renews the Epic login at startup, like +Then click **Import installed games** or, once logged in, **Import the whole +library**. Spotter renews the Epic login at startup, like GOG's. ## Xbox @@ -80,7 +81,7 @@ hourly request limit, which a large library can reach. 2. In the same browser, open [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie). It shows `{"npsso":"..."}`. Copy the 64-character value between the quotes. -3. Paste it into **NPSSO Token** and click **Import PlayStation**. +3. Paste it into the **NPSSO token** field and click **Import PlayStation**. The NPSSO token is a login cookie for your Sony account: treat it like a password. It expires after about two months, and signing out of @@ -91,15 +92,15 @@ individual trophies. ## Nintendo -Nintendo offers no library API. Add your games with **Add Game** in the -sidebar. +Nintendo offers no library API. Add your games on the **Add game** page, from +the sidebar. ## After an import - Importing again updates playtime, achievements and store details, adds new games, and keeps your status, rating and notes. -- Covers download after each import unless **Auto-download covers on - import** is off in Preferences. +- Covers download after each import unless **Download covers after an + import** is off in Preferences, under Imports. - Every import writes `spotter_auto_backup.json` and a line per step to `import.log`; their folders are listed in [data-model.md](../internals/data-model.md#where-the-data-lives). diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 961a606..276e6e7 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -78,7 +78,7 @@ auto-backup) and reports it, and the files on disk stay as they were. │ [Spotter ⚙]───┼──►│ Preferences (replaces the content area; │ │ │ │ same button or Close goes back) │ │ Library ──────┼──►│ Library ──► Game detail │ -│ + Add Game ───┼──►│ Add Game │ +│ Add game ─────┼──►│ Add game │ │ Statistics ───┼──►│ Statistics │ │ Import ───────┼──►│ Import (one card per store) │ │ Profile ──────┼──►│ Profile (name, keyring state, data paths, │ @@ -88,7 +88,8 @@ auto-backup) and reports it, and the files on disk stay as they were. ``` The sidebar stays on every screen. Keyboard: `Esc` cancels a pending delete, -`Ctrl+F` focuses the search, and `Ctrl+A` selects every game the library +or else closes Preferences, or else goes back to the library; `Ctrl+F` focuses +the search, and `Ctrl+A` selects every game the library currently shows (`Cmd` instead of `Ctrl` on macOS). ## Import merge @@ -113,19 +114,28 @@ and, unless turned off, downloads the missing covers. ## Look and preferences -Colours come from the active colony-ui theme (`colony_ui::Palette` roles, -`app_tint` for platform and status colours, `contrast_on` for text on a fill), -never from literals. Each view calls `Typography::current()` once and sizes -every text with `typo.sz(base)`, so the font size and the accessibility text -size both apply. Animations check `colony_ui::effects_enabled()`, which is false -under reduced motion. +Colours come from the active colony-ui theme, never from literals: the +`colony_ui::theme::Palette` roles (`Palette::TEXT_MUTED()`, `Palette::BG_CARD()` +...), read wherever a widget is drawn, including inside style closures, and +`contrast_on` for text on a filled button. Platforms and game statuses keep +one colour on every theme: `Platform::accent_key` and `GameStatus::accent_key` +name a shared accent swatch, which `theme::shared_accent` turns into a colour. +The style helpers (cards, chips, buttons, pills) are in `theme.rs`. + +Text goes through `theme::ui_text(content, base)`, which sizes it with +`colony_ui::sz(base)`, so the Appearance and Accessibility text sizes both +apply, sets it in the interface font, so the dyslexia font reaches it, and +keeps the Nerd Font glyphs inside it in the glyph font. Animations, the import +cascade included, check `colony_ui::effects_enabled()`, which is false when +**Animations** is off or reduced motion is on. The preferences file is Spotter's own struct with colony-ui's `StandardPreferences` flattened into it. At startup Spotter loads it, calls `StandardPreferences::apply()` and sets the locale; a change on the Preferences page goes through `StandardPreferences::update()` and is saved at once. Text is -looked up by key in Spotter's en/fr tables merged with colony-ui's shared ones, -and a test keeps the two locales' key sets identical. +looked up by key (`i18n::t`, `tf`, `tn`) in Spotter's own en/fr tables +(`src/i18n/en.json`, `src/i18n/fr.json`), then in colony-ui's shared labels; +tests keep the two locales' keys and placeholders identical. ## Credentials @@ -148,6 +158,7 @@ for a secret it does hold. | `iced` (with `image`) | GUI | | `colony-ui` | themes, typography, fonts, Preferences widgets, shared labels, folder paths | | `rusqlite` (bundled) | SQLite, compiled in; no system library needed | +| `dirs` | the home folder, for the Epic Games local scan | | `ureq` (with `json`) | blocking HTTP | | `scraper` | Steam store and achievement pages | | `keyring`, `keyring-core` | OS keyring, and its mock store in tests | @@ -156,8 +167,8 @@ for a secret it does hold. | `thiserror` | `SpotterError` | | `winresource` (build) | the Windows icon and version resource | -Every version is pinned exactly in `Cargo.toml`, and `Cargo.lock` is committed; -CI builds with `--locked`. +Every dependency names its full version in `Cargo.toml`, and `Cargo.lock` is +committed; CI builds with `--locked`. The release profile uses thin LTO, one codegen unit, `opt-level = 3`, and strips symbols. diff --git a/docs/internals/data-model.md b/docs/internals/data-model.md index ff95050..7ef1450 100644 --- a/docs/internals/data-model.md +++ b/docs/internals/data-model.md @@ -134,8 +134,9 @@ Profile page says so. The matching database columns stay empty. ### Preferences `preferences.json` holds Spotter's own preferences and colony-ui's -`StandardPreferences`, flattened into one object. Every field is read on its -own, so one bad value only resets that field. The keys are listed in +`StandardPreferences`, flattened into one object, with a `version` field (2 +since the move out of the database). Every field is read on its own, so one +bad value only resets that field. The keys are listed in [guide/configuration.md](../guide/configuration.md). ## Database schema diff --git a/docs/internals/development.md b/docs/internals/development.md index 40f72ef..3da6cda 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -41,17 +41,18 @@ fresh temporary directory through `db::open_at`, and the keyring tests use |---|---| | `tests/unit_tests.rs` | models and enums, preferences parsing and versioning, the database (CRUD, batch save, achievements, playtime history, profile, export and import), CSV and JSON export, errors | | `tests/integration_tests.rs` | each importer's parser against recorded Steam, PlayStation, Xbox, GOG and Epic responses, image validation, the rate limiter, the import log, URL encoding | -| `#[cfg(test)]` modules in `src/` | Steam HTML scraping, multi-byte title and description cuts, keyring moves, the write refusal after a failed load, the folders and their migration, the en/fr key parity, the window icon | +| `#[cfg(test)]` modules in `src/` | Steam HTML scraping, multi-byte title and description cuts, keyring moves, the write refusal after a failed load, the folders and their migration, `preferences.json` and the import of 0.3 settings, the en/fr tables, the Preferences toggle and `Esc`, imports without effects, the window icon | Not covered by automated tests: live store requests (the importers are only tested against recorded responses), the login flows end to end, the import merge in `handlers/data.rs`, and rendering. A change to a view, the Preferences page or the French layout needs a look on a -real desktop at both ends of the text scale (0.72x and 1.68x). +real desktop at both ends of the text scale (0.7225x and 1.68x). ## Before a pull request -CI runs on Linux, Windows and macOS, and again on Rust 1.90: +CI runs these on Linux, Windows and macOS with the latest stable Rust, then +builds and tests again on exactly Rust 1.90: ```bash cargo fmt --check @@ -86,13 +87,26 @@ reads run alongside a write. A new column goes into both `init_tables` and the **Secrets.** A new credential is a `UserProfile` field listed in `keyring::secrets`, so it goes to the keyring and never into the database. -**Look.** Colours come from colony-ui's palette roles and text sizes from -`Typography::current()` and `typo.sz(base)`; no colour literals and no raw -`.size(N)`. Gate animations on `colony_ui::effects_enabled()`. - -**Text.** Every user-visible string is a key, present in both the English and -the French table (a test fails otherwise). French uses the vous form and a -space before `:`, `;`, `!` and `?`. Store and product names stay untranslated. +**Look.** Take every colour from the active palette +(`colony_ui::theme::Palette::*()`), in view code and in style closures alike, +and reuse the style helpers in `theme.rs`; a colour literal would ignore the +user's theme and high contrast. Write text with `theme::ui_text(content, base)` +rather than `text(..).size(N)`, and give a text input +`.size(sz(N)).font(ui_font())`, so both text sizes and the dyslexia font reach +it. Gate animations on `colony_ui::effects_enabled()`. + +**Text.** Views hold no text: they call `i18n::t(key)`, `i18n::tf(key, vars)` +to fill `{name}` placeholders, or `i18n::tn(key, count, vars)` for a string that +depends on a count (`key_one` and `key_other`; French also uses the singular +for zero). Numbers with decimals and percentages go through `i18n::decimal` and +`i18n::percent`, which follow French typography (`12,5 %`). English is +canonical: name a new key after its English meaning and write the English first, +then the French. `cargo test` fails when `en.json` and `fr.json` hold different +keys, when a string is empty or the placeholders differ, when a key is missing +or unused, or when one repeats a shared colony-ui label. French uses the vous +form and a non-breaking space before `:`, `;`, `!` and `?`. Store, program and +theme names stay untranslated. Game statuses are stored by their English name +(`GameStatus`'s `Display`) and shown through `GameStatus::label`. **Files.** Spotter's own paths come from `paths::layout()` in `src/paths.rs`, built on `colony_ui::paths` (`config_dir`, `data_dir`, `cache_dir`); never From a8a86e6ab7d57fa5322e5a628414b48be483cf6b Mon Sep 17 00:00:00 2001 From: MotherSphere <linhajahad@gmail.com> Date: Wed, 14 Oct 2026 12:07:19 +0200 Subject: [PATCH 6/6] docs: replace the last dash in .gitattributes with a colon --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index ce5396e..5bd754d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ # Normalize line endings: always LF in the repo, checkout as-is on all platforms * text=auto eol=lf -# Binaries — no conversion +# Binaries: no conversion *.png binary *.jpg binary *.ico binary