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 diff --git a/README.md b/README.md index 1e02cb8..8ee30d4 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,229 @@ -# 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** - the shared Colony themes and accents, two text sizes, sidebar width, compact mode -- **Accessibility** - high contrast, a dyslexia-friendly font, reduced motion, large click targets, status labels -- **Preferences** - language, date format, notifications, start screen, default status/platform -- **English and French** - the interface follows the system language, or the one picked in Preferences -- **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`). +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). -## Project Structure + -### 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 a file of its config folder that only your user can read 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 Preferences; 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/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 | +| 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). +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; +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..a7e3ef6 --- /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 + `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 + 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/API_INTEGRATIONS.md b/docs/API_INTEGRATIONS.md deleted file mode 100644 index 48fee5f..0000000 --- a/docs/API_INTEGRATIONS.md +++ /dev/null @@ -1,164 +0,0 @@ -# API Integrations - -Spotter imports games from 5 platforms. Each has a distinct authentication model and data source. - -## Steam - -**Module:** `steam.rs`, `steam_auth.rs` - -### Authentication - -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). - -### Endpoints - -| 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) | - -### Import Flow - -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 - -### Scraped Data - -From store HTML pages: -- **Genre:** `text` -- **Release date:** `
...
DATE
` -- **Cover:** `` -- **Review %:** Score extracted from review section -- **Tags:** From tag elements on the store page - -## 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` - -Token refresh is automatic on 401 responses. - -### Endpoints - -| 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. - -## Epic Games - -**Module:** `epic.rs` - -### Authentication - -None required. Reads local manifest files directly. - -### Data Source - -Scans `.item` JSON manifests from the Epic Games Launcher: - -| OS | Path | -|----|------| -| Windows | `C:\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. - -## 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 -``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 7c88cb3..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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. - -## 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 -│ ├── preferences.rs Preferences page on the shared Colony kit -│ ├── 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, Preferences, Platform, Achievement, etc. -├── i18n/ English and French strings (en.json, fr.json) and t/tf/tn -├── db.rs SQLite: schema, CRUD, playtime history, export/import -├── paths.rs File locations (colony_ui::paths) and the copy from Spotter 0.3 -├── preferences.rs preferences.json: lenient load, save, import from Spotter 0.3 -├── theme.rs Icons and style helpers on the Colony palette -├── 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) -``` - -## 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) -``` - -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 - -``` -┌──────────────┐ ┌────────────────────────────────────┐ -│ 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 │ │ Preferences (from the Spotter name)│ -│ Completed: Y│ │ │ -│ Unplayed: Z │ └────────────────────────────────────┘ -│ │ -│ [Username] │ -└──────────────┘ -``` - -## Key Architectural Patterns - -### Message-Driven Updates - -Every user interaction produces a `Message` variant. The `update()` function pattern-matches on it, mutates state, and optionally returns an async `Task`. - -```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 -``` - -### Async via Thread Spawning - -Iced uses a `Task` abstraction. Spotter wraps blocking operations in `std::thread::spawn`: - -```rust -fn spawn_task(f: F, msg: fn(Result) -> M) -> Task -``` - -This runs `f` on a new thread and maps the result through `msg`. - -### Import Merge Logic - -When importing games from a platform, deduplication uses a multi-key index: - -1. **Platform ID match** (e.g., `steam_appid`) — most reliable -2. **Title + Platform match** — for games without IDs -3. **Title-only match** — last resort - -Existing user data (ratings, notes, status) is always preserved. Only empty metadata fields are backfilled from imports. - -### Theme System - -Colours come from colony-ui. `StandardPreferences::apply` sets the theme, accent and high contrast at startup and on every change; widgets read the active palette wherever they are drawn, including inside style closures: - -```rust -text(label).color(Palette::TEXT_MUTED()) -container(content).style(theme::card_style(Palette::BG_CARD(), Palette::BORDER_SUBTLE(), 8.0)) -``` - -Platforms and game statuses keep one colour on every theme: a shared accent swatch picked by key (`Platform::accent_key`, `GameStatus::accent_key`). - -## 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 -``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md deleted file mode 100644 index 8e358cd..0000000 --- a/docs/CONFIGURATION.md +++ /dev/null @@ -1,116 +0,0 @@ -# Configuration - -All preferences are saved to `preferences.json` in Spotter's config folder (`~/.config/Colony/Spotter/preferences/preferences.json` on Linux; see [Data Storage](../README.md#data-storage) for the other platforms) and applied immediately on change. There is no Save button. - -## The Preferences page - -Clicking **Spotter** at the top of the sidebar opens Preferences in place of the current screen; clicking it again, the page's Close button or Esc goes back to that screen. The page is the shared Colony one (`colony_ui::widgets`): General, Appearance and Accessibility come first, then Spotter's own Library and Imports. Every section starts collapsed. - -### General - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `language` | `"en"`, `"fr"` or none | none | Interface language; none follows the system (`LC_ALL`, `LC_MESSAGES`, then `LANG`) | -| `start_screen` | StartScreen | Library | Screen shown on launch (Library / Statistics / Import / Profile) | -| `notifications_enabled` | bool | true | Show toast notifications for success messages (errors always show) | -| `toast_duration` | ToastDuration | Normal (4 s) | Auto-dismiss duration: Short (2 s), Normal (4 s), Long (8 s) | -| `date_format` | DateFormat | YYYY-MM-DD | Date display: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY | - -### Appearance - -Theme, accent, text size, effects and a live preview come from the shared kit (see below). Spotter adds a Layout section: - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `sidebar_width` | u16 | 220 | Sidebar width in pixels (160-400, presets: 180/220/280) | -| `compact_list` | bool | false | Reduced spacing in game list (4px vs 8px) | -| `show_covers_in_list` | bool | true | Show cover thumbnails in library | - -### Accessibility - -Vision, Motion and Reading come from the shared kit. Spotter adds Color cues and Navigation: - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `show_status_labels` | bool | true | Show text labels alongside status dots | -| `large_click_targets` | bool | false | Increase button padding (8px to 12px) | - -### Library - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `default_status` | GameStatus | Unplayed | Default status for new games | -| `default_platform` | Platform | Steam | Default platform for new games | -| `default_sort_order` | SortOrder | Title A-Z | Default library sort | -| `remember_filters` | bool | true | 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 | -| `confirm_before_delete` | bool | true | Show confirmation dialog before deleting a game | - -### Imports - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `download_covers_auto` | bool | true | Automatically download cover art on import | - -### Shared Colony preferences - -Theme, accent, text sizes and the accessibility switches are the ones every Colony program has (`colony_ui::preferences::StandardPreferences`), saved in the same file under the same names: - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `selected_theme`, `selected_variant` | string | `gruvbox`, `dark` | A shared Colony theme; an unknown one falls back to Gruvbox dark | -| `selected_accent` | string | none | Accent override (`red`, `orange`, `yellow`, `green`, `blue`, `indigo`, `violet`, `amber`); none uses the theme's own | -| `auto_accent` | bool | false | Use the theme's accent even when one is picked | -| `font_size` | small / default / large | default | Appearance text size | -| `animations` | bool | true | Animations and fades | -| `high_contrast` | bool | false | Push text and borders away from the background | -| `dyslexia_font` | bool | false | Use OpenDyslexic | -| `reduce_motion` | bool | false | Turn every animation off | -| `text_size_a11y` | small / default / large / xlarge | default | Accessibility text size, multiplied with `font_size` | - -## Upgrading from Spotter 0.3 - -Spotter 0.3 and earlier kept the settings in the library database, with three dark themes, six accents and an interface scale of its own. The first start of a newer Spotter imports them into `preferences.json`: - -| Old setting | Becomes | -|-------------|---------| -| `theme_mode` Dark / Darker / Midnight | Tokyo Night night / Material deep ocean / Modus vivendi | -| `accent_color` Blue, Cyan / Purple / Green / Gold / Red | `blue` / `violet` / `green` / `amber` / `red` | -| `ui_scale` Small / Normal / Large | `text_size_a11y` small / default / large | - -The old scale enlarged the whole window; the text sizes now enlarge the text only, and the Appearance and Accessibility sizes multiply (0.7225x to 1.68x). - -## Style Helpers - -Spotter defines no colour of its own. Widgets read the active Colony palette (`colony_ui::theme::Palette`, for example `Palette::TEXT_MUTED()`), so a theme, accent or high-contrast change reaches every screen. Platforms and game statuses use the shared accent swatches (`theme::shared_accent`), the same on every theme; text on a filled button takes `colony_ui::contrast_on` of the fill. - -Reusable style functions in `theme.rs`: - -| Function | Description | -|----------|-------------| -| `card_style(bg, border, radius)` | Rounded card container | -| `chip_style(active, color, radius)` | Chip toggle: tinted with `color` when active, outlined otherwise | -| `transparent_btn_style()` | Invisible button wrapper | -| `primary_btn_style(bg)` | Solid filled action button, label in `contrast_on(bg)` | -| `outline_btn_style(color)` | Outlined button on any surface | -| `disabled_btn_style()` | Dimmed disabled button | -| `loading_btn_style()` | Warning-tinted state for imports and logins in progress | -| `danger_btn_style()` | Delete action | -| `accent_card_style(accent)` | Card with a border tinted by `accent` | -| `dot_style(color, size)` | Small circular indicator | -| `pill_style(bg, border)` | Rounded status badge | - -## Languages - -The interface ships in English and French. Spotter's own strings are in `src/i18n/en.json` and `src/i18n/fr.json`; the Preferences skeleton and the theme and accent names come from `colony_ui::i18n`. Views never hold 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 the French typography (`12,5 %`). - -English is canonical: name a new key after its English meaning, write the English first, then the French in both files. `cargo test` fails when the two files 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. Platform, program and theme names are never translated. Game statuses are stored in the database by their English name (`GameStatus` `Display`) and shown through `GameStatus::label`. - -## Preferences Schema Versioning - -Preferences include a `version` field (currently `2`: the move to `preferences.json`), and `Preferences` is `#[serde(default)]`, so a missing field takes its default. - -`preferences::parse` also reads the file one field at a time when the whole struct does not parse: a value this build does not accept (for example one added by a newer version) falls back to that field's default, and every other preference keeps its stored value. - -If the startup load fails anyway, for example because `preferences.json` is not JSON at all, Spotter shows an error and runs on defaults, and refuses every write of saved data for the rest of the session: preferences, 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md deleted file mode 100644 index 26e5157..0000000 --- a/docs/DATA_MODEL.md +++ /dev/null @@ -1,220 +0,0 @@ -# Data Model - -## Core Structures - -### Game - -The primary entity. 21 fields tracking a game across platforms. - -```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 -} -``` - -**Builder pattern:** - -```rust -GameBuilder::new("Elden Ring", Platform::Steam) - .playtime(12000) - .achievements(32, 42) - .status(GameStatus::Playing) - .rating(9) - .genre("Action RPG") - .steam_appid(1245620) - .build() -``` - -### Platform - -``` -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) -``` - -### GameStatus - -``` -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) -``` - -### Achievement - -Per-game achievement data. Only available for Steam games currently. - -```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) -} -``` - -### UserProfile - -Stores user identity and platform credentials. - -```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) -} -``` - -### Preferences - -The user's preferences, saved to `preferences.json`, not to the database. See [CONFIGURATION.md](CONFIGURATION.md) for details. - -## Database Schema - -SQLite with WAL mode and foreign keys enabled. - -### games - -```sql -CREATE TABLE games ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - platform TEXT NOT NULL, - playtime_minutes INTEGER DEFAULT 0, - achievements_unlocked INTEGER DEFAULT 0, - achievements_total INTEGER DEFAULT 0, - status TEXT DEFAULT 'Unplayed', - rating INTEGER, - genre TEXT DEFAULT '', - last_played TEXT DEFAULT '', - cover_url TEXT DEFAULT '', - steam_appid INTEGER, - gog_id TEXT, - epic_id TEXT, - xbox_id TEXT, - psn_id TEXT, - notes TEXT DEFAULT '', - description TEXT DEFAULT '', - release_date TEXT DEFAULT '', - review_percent INTEGER, - tags TEXT DEFAULT '' -); -``` - -**Indexes:** `platform`, `status`, `steam_appid`, `title`, `rating`, `last_played` - -### 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) -); -``` - -### playtime_history - -```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 -); -``` - -### user_profile - -Single-row table (id = 1) storing `UserProfile` fields as individual columns. - -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, or, when no keyring is usable, in `credentials/tokens.json` in the config folder. A value here comes from Spotter 0.3 or earlier, or from a save that could not write that file, and `keyring.rs` moves it out on the next start. - -### settings - -Single-row table (id = 1) holding the settings Spotter 0.3 and earlier saved, as JSON. A newer Spotter reads it once, to import it into `preferences.json`, and leaves it as it was. - -```sql -CREATE TABLE settings ( - id INTEGER PRIMARY KEY, - data TEXT NOT NULL -- JSON blob -); -``` - -## Storage Locations - -Under `Colony/Spotter/` in the platform's config, data and cache folders (`colony_ui::paths`): - -| | Linux | macOS | Windows | -|---|---|---|---| -| Config | `~/.config/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | -| Data | `~/.local/share/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | -| Cache | `~/.cache/Colony/Spotter/` | `~/Library/Caches/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` | - -**Subdirectories:** - -``` -/ -├── preferences/ -│ └── preferences.json # Preferences, with the shared Colony ones -└── credentials/ - └── tokens.json # Platform tokens, only when no OS keyring is usable - -/ -├── library/ -│ └── spotter.db # SQLite database -├── exports/ -│ ├── spotter_export.json # JSON backup file -│ └── spotter_auto_backup.json -└── logs/ - └── import.log - -/ -├── covers/ # Game cover art (JPEG) -└── achievement_icons/ # Achievement icons (colored + grayscale) -``` - -Spotter 0.3 and earlier kept `data/spotter.db`, `exports/`, `logs/` and `cache/` directly in the config folder. `paths.rs` copies the database, exports and log once (`.migrated-from-0.3` in the data folder marks it done), leaves the old files in place, and keeps using them if the copy fails. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md deleted file mode 100644 index 9c5c4e7..0000000 --- a/docs/DEVELOPMENT.md +++ /dev/null @@ -1,146 +0,0 @@ -# Development Guide - -## 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` - -## Build - -```bash -# Debug build -cargo build - -# Release build (optimized, stripped) -cargo build --release -``` - -The release binary is at `target/release/spotter`. - -## Run - -```bash -cargo run -``` - -On first launch, Spotter creates its data directories and an empty SQLite database. - -## Test - -```bash -# Run all tests -cargo test -``` - -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. - -### Test Categories - -| Category | Count | What's tested | -|----------|-------|---------------| -| Playtime display | 3 | Format hours/minutes strings | -| Models | 20+ | Enum roundtrips, Display impls, distinct platform and status colours, settings serialization | -| Database | 8 | CRUD, batch save, achievements, playtime history, export/import, profile, settings | -| API client | 3 | URL encode/decode, roundtrip | -| Error | 2 | Display format, String conversion | - -### Coverage Gaps - -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) -├── i18n/ # en.json, fr.json and the t/tf/tn lookup -├── db.rs # SQLite operations -├── paths.rs # File locations, copy from Spotter 0.3 -├── preferences.rs # preferences.json load/save/import -├── theme.rs # Icons and style helpers on the Colony palette -├── 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 - ├── preferences.rs # Preferences page - ├── 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 # Preferences, languages and theming -└── DEVELOPMENT.md # This file -``` - -## Key Conventions - -### Error Handling - -The codebase currently uses `Result` 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(f: F, msg: fn(Result) -> M) -> Task -``` - -This spawns a thread and maps the result through a `Message` variant. - -### Database Access - -Always open a fresh connection per operation via `db::open()`. The connection uses WAL mode for concurrent reads. - -### Styling - -Take every colour from the active palette (`colony_ui::theme::Palette::*()`), in view code and in style closures alike, and the style helpers in `theme.rs`. Never write a colour literal: it would ignore the user's theme and high-contrast setting. - -Write text with `theme::ui_text(content, base)` rather than `text(..).size(N)`: it sizes the text with `colony_ui::sz(base)`, so both text-size preferences scale it, sets it in the interface font, so the dyslexia font reaches it, and keeps the Nerd Font glyphs inside it in the glyph font. A text input takes `.size(sz(N)).font(ui_font())`. - -## Environment Variables - -| Variable | Purpose | -|----------|---------| -| `XDG_CONFIG_HOME` | Override config directory (Linux). | - -## Common Warnings - -The following `dead_code` warnings are expected and can be ignored: - -- `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 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 new file mode 100644 index 0000000..8c238a3 --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,100 @@ +# Configuration + +Every preference is set on the **Preferences** page. Open it with the +**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 +**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 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 +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; 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 | +| 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 + +| 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 + +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`, 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. + +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..3be319b --- /dev/null +++ b/docs/guide/platform-setup.md @@ -0,0 +1,106 @@ +# Platform setup + +Everything below happens on the **Import** screen: each store has a card with +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 **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 on the **Add game** page | + +## Steam + +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 the **API key** field. +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 **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 **Finish**. 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 **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 + **Finish**. The code alone, or an address with `?code=`, works too. + +Then click **Import installed games** or, once logged in, **Import the whole +library**. 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 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 +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 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 **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/api-integrations.md b/docs/internals/api-integrations.md new file mode 100644 index 0000000..1f7042b --- /dev/null +++ b/docs/internals/api-integrations.md @@ -0,0 +1,143 @@ +# API integrations + +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. + +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)). + +## HTTP client + +All requests go through `ureq` agents built in `api_client.rs`: + +| | | +|---|---| +| 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 | + +## Steam + +`steam/mod.rs` (import) and `steam/auth.rs` (login). + +**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. + +| 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 | + +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 + +`gog/mod.rs` (import) and `gog/auth.rs` (login). + +**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. + +| 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) | + +GOG's API has no achievement data for Spotter. + +## Epic Games + +`epic/`. Two independent imports, picked by whether the user is logged in. + +**Local scan** (`local.rs`), no login. Reads the Epic Games Launcher's `.item` +manifests and Legendary's `installed.json` from: + +| System | Folder | +|---|---| +| Windows | `%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests\` | +| macOS | `/Users/Shared/Epic Games/Launcher/Data/Manifests/` | +| 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. Games are added by hand with **Add Game**. diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000..22807e1 --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,180 @@ +# Architecture + +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 + +``` +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 +├── preferences.rs preferences.json: lenient load, save, import of the 0.3 +│ settings +├── paths.rs where every file lives (on colony_ui::paths), and the +│ copy of the 0.3 files +├── i18n/ en.json, fr.json and the t / tf / tn lookup +├── 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 icons, ui_text and the 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 +``` + +`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 input / keyboard + │ + ▼ + Message ──────► Spotter::update() ──► state change ──► Spotter::view() + ▲ │ + │ ▼ + │ Task (spawn_task / spawn_write) + │ │ + │ ▼ + └──── result ◄── worker thread: store requests, SQLite, files +``` + +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 +``` + +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. + +## Screens + +``` +┌───────────────┐ ┌──────────────────────────────────────────┐ +│ [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 │ └──────────────────────────────────────────┘ +└───────────────┘ +``` + +The sidebar stays on every screen. Keyboard: `Esc` cancels a pending delete, +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 + +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: + +1. its store id (`steam_appid`, `gog_id`, `epic_id`, `xbox_id`, `psn_id`); +2. its title and platform; +3. its title alone. + +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. + +After an import Spotter saves the library, writes `spotter_auto_backup.json`, +and, unless turned off, downloads the missing covers. + +## Look and preferences + +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 (`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 + +`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 `/credentials/tokens.json` for the rest of the +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. + +## Dependencies + +| 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 | +| `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 | +| `serde`, `serde_json`, `csv` | JSON and CSV | +| `chrono` | dates | +| `thiserror` | `SpotterError` | +| `winresource` (build) | the Windows icon and version resource | + +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 new file mode 100644 index 0000000..d472732 --- /dev/null +++ b/docs/internals/data-model.md @@ -0,0 +1,227 @@ +# Data model + +## Where the data lives + +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: + +| | 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/` | + +``` +/ +├── preferences/ +│ └── preferences.json every preference, see guide/configuration.md +└── credentials/ + └── tokens.json 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 +``` + +`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 +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 +(`/.migrated-from-0.3`) so the check runs once, and never deletes the old +folder. If the copy fails, it keeps using the old +files where they are (`/data/spotter.db`, `/exports/`, +`/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 `/data/` can still hold the platform tokens +unencrypted. + +## Records + +### Game + +The library entry, one row of the `games` table. + +```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 +} +``` + +The store ids are what an import matches on before it falls back to the title; +see [architecture.md](architecture.md#import-merge). + +### Achievement + +```rust +Achievement { + 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 +} +``` + +Steam and Xbox imports store one row per achievement. PlayStation only fills +the game's unlocked and total counts. + +### UserProfile + +```rust +UserProfile { + 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 +} +``` + +`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/tokens.json` instead, and the +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, 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 + +`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 + +```sql +CREATE TABLE games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + platform TEXT NOT NULL, + playtime_minutes INTEGER DEFAULT 0, + achievements_unlocked INTEGER DEFAULT 0, + achievements_total INTEGER DEFAULT 0, + status TEXT DEFAULT 'Unplayed', + rating INTEGER, + genre TEXT DEFAULT '', + last_played TEXT DEFAULT '', + cover_url TEXT DEFAULT '', + steam_appid INTEGER, + gog_id TEXT, + epic_id TEXT, + xbox_id TEXT, + psn_id TEXT, + notes TEXT DEFAULT '', + description TEXT DEFAULT '', + release_date TEXT DEFAULT '', + review_percent INTEGER, + tags TEXT DEFAULT '', + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); +``` + +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, + 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) +); +``` + +### playtime_history + +```sql +CREATE TABLE playtime_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 +); +``` + +The Statistics screen charts the last 30 days of it. + +### user_profile + +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 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 + +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 new file mode 100644 index 0000000..3da6cda --- /dev/null +++ b/docs/internals/development.md @@ -0,0 +1,117 @@ +# Development + +## Prerequisites + +- **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: + + ```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 + ``` + +## Build and run + +```bash +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 +``` + +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 +cargo test --locked +``` + +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. + +| 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, `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.7225x and 1.68x). + +## Before a pull request + +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 +cargo clippy --locked --all-targets -- -D warnings +cargo build --locked +cargo test --locked +cargo audit +``` + +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. + +## Conventions + +**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/`. + +**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. + +**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. + +**Secrets.** A new credential is a `UserProfile` field listed in +`keyring::secrets`, so it goes to the keyring and never into the database. + +**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 +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. 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