diff --git a/README.md b/README.md index dbb25f7..06ef69b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,45 @@ so there was no resource seal and Gatekeeper refused it outright rather than asking. `scripts/after-pack.js` signs the bundle during the build now, and the result verifies as `valid on disk`. +## Signing in, and titles that cost money + +**Nothing in this client knows anything about a particular store.** What a title costs, +whether it needs an account, where to buy it and where to sign in all arrive from the +catalog's own server — WarpEngine 0.5 answers `GET /api/service` with what it offers, and +puts an `access` block on every catalog entry. A client that carried those facts would +work for exactly one shop; this one asks. + +Where the server offers no sign-in — every WarpEngine before 0.5, and any store that +sells nothing — the window shows none, and behaves exactly as it always did. + +Where it does: + +- the side menu grows an **Account** block: *Sign in…*, and *Sign out* once you are; +- signing in shows a **short code**. Your browser opens on the store's own page and you + type the code there; approving it signs this device in. Nothing is typed into this + window, and no password ever reaches it — that is the whole reason for the detour; +- the token is kept in the **OS keychain** (Keychain, libsecret, DPAPI) through + Electron's `safeStorage`, one per store. Where no keychain is available it is not + stored at all rather than written out in the clear: the cost is signing in again next + run. + +On a card, what you may do with a title is separate from what this machine can run: + +- **owned** or free → *Install*, as before; +- **not owned** → the **price** on the card and a **Buy** button, which opens the store's + page in your browser. Buying happens there, not here — a checkout rebuilt in this + window would be a second place to get card handling wrong. **Refresh** afterwards and + the card becomes an *Install*; +- **signed out, catalog gates it** → *Sign in to install*, because the catalog cannot say + whether it is yours until it knows who is asking. + +Two new categories go with it: **Owned** and **To buy**. Owning something is not the +same as having installed it, which is the point of the first one. + +A title nobody has bought is **not** dimmed. That treatment belongs to what this +*machine* cannot do — an unsupported platform, no build for this architecture — and +there is nothing wrong with the machine here. + ## Which store it installs On first run the client fetches the registry and offers what it finds. One store @@ -122,11 +161,14 @@ Everything that is not a title lives in the **side menu** on the left, and the catalog into different folders show their folder instead of their id, because the id would not tell them apart. **Add a store…** brings up the registry picker, the same one the first run offers. +- **Account** appears only where the catalog offers a sign-in, and holds *Sign in…* or + *Sign out* — see above. - **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed one at a time from their own cards; there is no install-everything button. - **Categories** narrows the grid, one category at a time, with the count next to each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per **platform** (`godot`, `tic80`, `love`, …) and per **kind** (native or hosted). + Where the catalog gates anything, **Owned** and **To buy** join them. The axes are built from what the catalog actually contains — a platform with no titles is not listed, and a category that disappears under you falls back to *Everything* rather than leaving an empty grid. There is no genre in a diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3659b27..bff65b5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,72 +1,34 @@ -# WarpEngine Client 2.3.0 +# WarpEngine Client 2.4.0 -**A card says which version you have, and offers the newer one.** Where the catalog has -moved on, an installed title's version line reads `0.1 → 0.3` instead of just the number -on your disk, so the card answers both questions a person came to it with: what is -installed, and is there anything better. +**The catalog can now say a title is not yours, and the client can do something about +it.** Where a store sells things, a card shows the **price** and a **Buy** button that +opens the store's own page; where you own it, an **Install** as before. Two new +categories go with it — **Owned** and **To buy** — because owning something is not the +same as having installed it. -Which version is installed was already recorded — that is what `state.json` has always -been for. What was missing was somewhere to act on it. +**Signing in, without a password ever reaching this window.** The side menu grows an +**Account** block, and signing in shows a short code: your browser opens on the store's +own page and you type it there. That detour is the point — a desktop application asking +for a password is a desktop application people should not be giving one to. The token +lives in the OS keychain (Keychain, libsecret, DPAPI), one per store, and *Sign out* +revokes it at the server as well as forgetting it here. -**The actions moved into a three-dot menu.** Installed cards now lead with **Play** (or -**Open** for a hosted title) and put the rest behind the ⋮ button beside it: +**None of this is knowledge about any particular store.** It all arrives from the +catalog's own server: WarpEngine 0.5 answers `GET /api/service` with what it offers, and +puts an `access` block on every entry. A client that carried those facts would work for +exactly one shop — this one asks, which is why the same build serves any of them. -| | | -|---|---| -| **Upgrade** | fetches whatever the catalog now has, replacing the old payload and menu entry. Greyed out when there is nothing newer | -| **Uninstall** | as before — the payload, the icon and the menu entry, and nothing else | +**A store with no sign-in shows none.** Every WarpEngine before 0.5 has no descriptor at +all, and a 0.5 store that sells nothing reports none either. Both read as "a plain +catalog", which is what this client assumed for its whole life until now, and the window +behaves accordingly: no Account block, no prices, no new categories. -Upgrade stays visible while disabled rather than appearing and disappearing: a menu whose -items come and go makes a person hunt for the one they used last time, and greyed out -already says "not now". +**A title nobody has bought is not dimmed.** The dimming and the dashed badge belong to +what this *machine* cannot do — an unsupported platform, no build for this architecture +— and there is nothing wrong with the machine when a title simply costs money. -Playing stays the headline action even when an upgrade is waiting. The version on the disk -still runs, and wanting to play it is not the same as wanting to wait for a download. - -**Package names have no spaces.** `WarpEngineClient-2.3.0-arm64.dmg` rather than -`WarpEngine Client-2.3.0-arm64.dmg`, and the same for the AppImage and both Windows -builds — a space in a release asset is a space in every `curl`, script and shell command -that ever touches it. The app is still called **WarpEngine Client**, which is what appears -in the Dock and in `/Applications`; only the file names changed. - -### Also - -The catalog refresh control's tooltip now says *Refresh the catalog*, because in Hungarian -it and the new Upgrade both wanted the word "Frissítés" and only one of them reloads a -list. - -### Opening it on macOS - -Ad-hoc signed, **not notarised**, so macOS asks first: - -```sh -xattr -dr com.apple.quarantine "/Applications/WarpEngine Client.app" -``` - -### What is attached - -The macOS package, built and verified on a Mac, plus the Linux (AppImage, deb) and -Windows (installer, portable) packages the pipeline builds when the tag is pushed. - -### Verified - -`make check` is clean: typecheck, lint, the headless smoke test and the window self-test. - -The upgrade path was tested on a sandbox store rather than reasoned about. Two titles were -installed, then one of their state records was rewritten to claim an older build — which is -exactly what the engine compares — and the window was asked what it would offer: - -``` -BombExpert [newer] Upgrade:on Uninstall:on -Rabbit Roller [current] Upgrade:off Uninstall:on -``` - -Pressing Upgrade runs the same call the window makes, and the record went from `0.1` to -`0.2` with the old payload removed first. The self-test now asserts that pairing on every -installed card, because a closed menu photographs identically whether or not its items are -right. - -The new package names were read off a real build (`WarpEngineClient-2.3.0-arm64.dmg`, -`…-arm64-mac.zip`) and checked against the release script's own file filter, which finds -both. The Windows names are `-Setup-` and `-Portable-` so the two `.exe` targets cannot -resolve to one name and overwrite each other. +**A bearer token is never sent to a host that did not issue it.** A gated download +answers with a redirect to signed storage, often somebody else's server, and some object +stores refuse a request outright when an `Authorization` header rides along with the +signature. The credential stops at the origin it belongs to; a redirect back to the +catalog keeps it. diff --git a/STRUCTURE.md b/STRUCTURE.md index ab5c8cf..a80be05 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -59,6 +59,8 @@ src/ infrastructure/ engine/ the store engine: catalog, releases, install, state dialects/ one per WarpEngine version's catalog shape + ServiceDescriptorClient what the catalog's server says it offers (GET /api/service) + DeviceSignInClient the device authorization grant, client side launchers/ .desktop, .app bundle, .lnk — the three hosts archive/ ZipArchive: a zip reader over node:zlib files/ StoreFileSystem: atomic writes and the delete guard @@ -66,7 +68,8 @@ src/ http/ HttpTextClient, StoreHttpClient, HttpStatusError json/ JsonRecord: reading data that came from elsewhere config/ BuildConfiguration: what was decided when this was packaged - electron/ ApplicationEnvironment and GameLauncher adapters + electron/ ApplicationEnvironment, GameLauncher, and the keychain + credential store main/ main.ts the entry point: one line of work ElectronApplication.ts lifecycle, single instance, self-test mode diff --git a/package-lock.json b/package-lock.json index 1e5a04f..9f478c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "warp-engine-client", - "version": "1.2.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warp-engine-client", - "version": "1.2.0", + "version": "2.4.0", "license": "MIT", "devDependencies": { "@types/node": "^26.2.0", diff --git a/package.json b/package.json index e7e60b3..759e6f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warp-engine-client", "productName": "WarpEngine Client", - "version": "2.3.0", + "version": "2.4.0", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "license": "MIT", "author": "Teletype Games ", diff --git a/src/application/mappers/GameDtoMapper.ts b/src/application/mappers/GameDtoMapper.ts index 1a763b7..2770ad0 100644 --- a/src/application/mappers/GameDtoMapper.ts +++ b/src/application/mappers/GameDtoMapper.ts @@ -1,3 +1,4 @@ +import { readAccessVerdict, type CatalogPrice } from '../../domain/models/CatalogAccess' import type { Game } from '../../domain/models/Game' import type { GameDto } from '../../shared/contracts/dto/GameDto' @@ -6,10 +7,11 @@ const ABSOLUTE_URL = /^https?:\/\// /** * A title as the window may see it. * - * Two decisions live here rather than in the renderer: the box art is resolved - * against the catalog's base URL, and whether a title can be launched is answered - * here — so the window never receives a filesystem path it could be talked into - * opening. + * Three decisions live here rather than in the renderer: the box art is resolved + * against the catalog's base URL, whether a title can be launched is answered here — + * so the window never receives a filesystem path it could be talked into opening — + * and the catalog's access block is reduced to a verdict and a printed price, because + * a view that had to reason about entitlement is a view with a rule in it. */ export class GameDtoMapper { public toDto (game: Game, catalogBaseUrl: string): GameDto { @@ -29,7 +31,10 @@ export class GameDtoMapper { launchable: this.isLaunchable(game), installable: game.installable, unavailableReason: game.unavailableReason, - unavailableDetail: game.unavailableDetail + unavailableDetail: game.unavailableDetail, + accessVerdict: readAccessVerdict(game.access), + priceLabel: formatPrice(game.access?.price ?? null), + purchaseUrl: game.access?.purchaseUrl ?? null } } @@ -49,3 +54,25 @@ export class GameDtoMapper { return game.menuEntryPath !== null || game.executablePath !== null } } + +/** + * A price as a person reads it, in the currency the catalog named. + * + * `Intl` with the *catalog's* currency and the system locale: the store decides what it + * charges in, the reader's machine decides where the symbol and the separators go. + * There is no conversion here and there must not be — inventing an exchange rate would + * be quoting a price nobody agreed to. + */ +function formatPrice (price: CatalogPrice | null): string | null { + if (price === null) return null + if (price.amountCents <= 0) return null + + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', currency: price.currency + }).format(price.amountCents / 100) + } catch { + // An unknown currency code: better the number and the code than nothing at all. + return `${(price.amountCents / 100).toFixed(2)} ${price.currency}` + } +} diff --git a/src/application/services/AccountService.ts b/src/application/services/AccountService.ts new file mode 100644 index 0000000..aceb596 --- /dev/null +++ b/src/application/services/AccountService.ts @@ -0,0 +1,112 @@ +import { + NO_ACCOUNT, type SignInOutcome, type SignInPrompt, type StoreAccount +} from '../../domain/models/StoreAccount' +import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' +import type { StoreSelectionService } from './StoreSelectionService' + +/** A sign-in that is under way: what to show, and how it ended. */ +export interface SignInSession { + readonly prompt: SignInPrompt + readonly finished: Promise +} + +export interface SignInResult { + readonly outcome: SignInOutcome + readonly account: StoreAccount +} + +/** + * Signing in to the store that is open, and out of it again. + * + * The waiting lives here rather than in the gateway because it is orchestration: a loop + * with a cancel and a deadline in it, over a port that only knows how to ask once. That + * split is also what keeps the port testable without a clock. + * + * One sign-in at a time, per application rather than per store: a second one started + * while the first is waiting would leave two loops racing to write the same token, and + * a person can only be at one browser tab anyway. + */ +export class AccountService { + private cancelled = false + private active: SignInSession | null = null + + public constructor ( + private readonly catalogGateway: StoreCatalogGateway, + private readonly selection: StoreSelectionService + ) {} + + /** Null where no store is open — the window asks before anything is chosen. */ + public async readAccount (): Promise { + const store = this.selection.findCurrentStore() + if (store === null) return NO_ACCOUNT + + return await this.catalogGateway.readAccount(store) + } + + /** + * Ask the store for a code, then keep polling until somebody answers. + * + * Returns as soon as there is something to show: the code has to be on screen while + * the polling happens, and a person cannot answer a code they have not seen yet. + */ + public async beginSignIn (clientName: string): Promise { + if (this.active !== null) return this.active + + const store = this.selection.requireCurrentStore() + const prompt = await this.catalogGateway.requestSignIn(store, clientName) + this.cancelled = false + + const session: SignInSession = { prompt, finished: this.awaitAnswer(prompt) } + this.active = session + return session + } + + /** Give up waiting. The code stays valid at the server until it expires by itself. */ + public cancelSignIn (): void { + this.cancelled = true + } + + public async signOut (): Promise { + const store = this.selection.findCurrentStore() + if (store === null) return NO_ACCOUNT + + this.cancelSignIn() + return await this.catalogGateway.signOut(store) + } + + private isCancelled (): boolean { + return this.cancelled + } + + private async awaitAnswer (prompt: SignInPrompt): Promise { + const store = this.selection.requireCurrentStore() + const deadline = Date.now() + prompt.expiresInSeconds * 1000 + + try { + while (!this.isCancelled()) { + await delay(prompt.intervalSeconds * 1000) + // Read through a method, not the field: cancelling happens *during* the delay + // above, and a flow analysis that only sees the loop condition concludes this + // can never be true. + if (this.isCancelled()) break + // The server's own expiry is the authority; this one only stops the loop when + // the server has stopped answering at all. + if (Date.now() > deadline) return { outcome: 'expired', account: await this.readAccount() } + + const result = await this.catalogGateway.pollSignIn(store, prompt.deviceCode) + if (result.state === 'approved') return { outcome: 'signedIn', account: result.account } + if (result.state === 'denied') return { outcome: 'denied', account: result.account } + if (result.state === 'expired') return { outcome: 'expired', account: result.account } + } + return { outcome: 'cancelled', account: await this.readAccount() } + } finally { + this.active = null + } + } +} + +async function delay (milliseconds: number): Promise { + await new Promise((resolve: () => void): void => { + setTimeout((): void => { resolve() }, milliseconds) + }) +} diff --git a/src/domain/models/CatalogAccess.ts b/src/domain/models/CatalogAccess.ts new file mode 100644 index 0000000..0abbb50 --- /dev/null +++ b/src/domain/models/CatalogAccess.ts @@ -0,0 +1,42 @@ +/** + * What a catalog says about getting one title. + * + * The vocabulary is the engine's and deliberately generic — `gated`, `entitled`, a + * price. One client reads many catalogs, so a field named after what a particular shop + * calls the thing it sells is a field that works in exactly one shop. + * + * Absent (`null` where this appears) is its own answer: an engine too old to have an + * opinion. That is not the same as "not gated", and only one of the two is a reason to + * offer somebody a sign-in. + */ +export interface CatalogAccess { + /** Downloading needs an entitlement. */ + readonly gated: boolean + /** For the signed-in caller; null when nobody was signed in to ask about. */ + readonly entitled: boolean | null + readonly price: CatalogPrice | null + /** Where a person goes to get it. Absolute — it opens in their own browser. */ + readonly purchaseUrl: string | null + /** Where a hosted build is played, when the catalog serves it somewhere of its own. */ + readonly webUrl: string | null +} + +export interface CatalogPrice { + readonly amountCents: number + readonly currency: string +} + +/** + * Can this caller install this title? + * + * Three answers, because the middle one is real: yes; no, and here is where to buy it; + * and "the catalog would tell you if you signed in". A client that collapsed the last + * two would either hide a title somebody owns or offer to sell them one they have. + */ +export type AccessVerdict = 'open' | 'entitled' | 'purchasable' | 'signInRequired' + +export function readAccessVerdict (access: CatalogAccess | null): AccessVerdict { + if (access?.gated !== true) return 'open' + if (access.entitled === true) return 'entitled' + return access.entitled === false ? 'purchasable' : 'signInRequired' +} diff --git a/src/domain/models/CatalogListing.ts b/src/domain/models/CatalogListing.ts index aeb46fa..cecd98b 100644 --- a/src/domain/models/CatalogListing.ts +++ b/src/domain/models/CatalogListing.ts @@ -1,4 +1,5 @@ import type { Game } from './Game' +import type { StoreAccount } from './StoreAccount' import type { StorePaths } from './StorePaths' /** One reading of a store's catalog. */ @@ -6,4 +7,13 @@ export interface CatalogListing { readonly games: readonly Game[] readonly skipped: readonly string[] readonly paths: StorePaths | null + /** + * Where this machine stands with the store, as of this reading. + * + * Part of the listing rather than a call of its own because it is the same answer + * from the same request: the catalog was fetched with whatever credential we hold, + * and what it said about entitlements is only meaningful next to whether anybody was + * signed in when it said it. + */ + readonly account: StoreAccount } diff --git a/src/domain/models/Game.ts b/src/domain/models/Game.ts index 51382fa..fb5170b 100644 --- a/src/domain/models/Game.ts +++ b/src/domain/models/Game.ts @@ -1,3 +1,5 @@ +import type { CatalogAccess } from './CatalogAccess' + /** How a title runs: unpacked on this machine, or served as a web build. */ export type GameMode = 'app' | 'web' @@ -41,4 +43,12 @@ export interface Game { readonly unavailableReason: UnavailableReason | null /** The engine's sentence for it, for a tooltip or the log. */ readonly unavailableDetail: string | null + /** + * What the catalog says about getting it, or null where it said nothing. + * + * Kept separate from `installable`: that one is about this machine — no build for + * this architecture — and this one is about this person. A title can be perfectly + * installable and still not yours. + */ + readonly access: CatalogAccess | null } diff --git a/src/domain/models/InstalledRecord.ts b/src/domain/models/InstalledRecord.ts index 0bfb7f2..a919794 100644 --- a/src/domain/models/InstalledRecord.ts +++ b/src/domain/models/InstalledRecord.ts @@ -6,8 +6,13 @@ import type { SelectedGame } from './SelectedGame' * This is the shape `state.json` carries, keyed `:`. Every path in it * is something the store put there and may therefore delete — which is why an * uninstall reads the record rather than guessing at paths. + * + * The catalog's `access` block is deliberately *not* part of it. Whether somebody may + * download a title is the server's answer to a question asked now; a copy of it on disk + * would go stale the moment a purchase or a refund happened, and a stale "yes" is the + * dangerous direction. What is installed stays installed either way. */ -export interface InstalledRecord extends SelectedGame { +export interface InstalledRecord extends Omit { /** The unpacked archive's directory; null for a hosted entry, which has none. */ readonly payload: string | null readonly executable: string | null diff --git a/src/domain/models/SelectedGame.ts b/src/domain/models/SelectedGame.ts index bb565a0..ea8b595 100644 --- a/src/domain/models/SelectedGame.ts +++ b/src/domain/models/SelectedGame.ts @@ -1,3 +1,4 @@ +import type { CatalogAccess } from './CatalogAccess' import type { UnavailableReason } from './Game' /** @@ -26,6 +27,8 @@ export interface SelectedGame { readonly createdAt: string | null /** `app` for a native archive, `web` for a hosted page. */ readonly mode: string + /** What the catalog says about getting it; null from an engine that cannot say. */ + readonly access: CatalogAccess | null } /** @@ -47,6 +50,8 @@ export interface UnavailableEntry { readonly reason: UnavailableReason /** The sentence behind the code, for a tooltip or the log. */ readonly detail: string + /** Carried here too: a title with no build for this machine can still have a price. */ + readonly access: CatalogAccess | null } /** What a survey of the catalog found: installable, why not, and what was skipped. */ diff --git a/src/domain/models/ServiceDescriptor.ts b/src/domain/models/ServiceDescriptor.ts new file mode 100644 index 0000000..8f483b7 --- /dev/null +++ b/src/domain/models/ServiceDescriptor.ts @@ -0,0 +1,43 @@ +/** + * What one catalog's server says about itself. + * + * This is how the client stops being built for a particular store. Whether there is a + * sign-in here, where it lives, whether any title can be gated — all of it used to be + * knowledge the client would have had to carry, and a client that carries it works for + * exactly one catalog. Now the server answers, and the same binary serves any of them. + * + * Every field is optional in practice: an engine older than 0.5 has no descriptor at + * all, and `DEFAULT_SERVICE_DESCRIPTOR` is what that means — a plain catalog, nothing + * gated, nobody to sign in as. That is what this client always assumed. + */ +export interface ServiceDescriptor { + readonly engineVersion: string | null + /** Whether any title in this catalog can require an entitlement. */ + readonly catalogGated: boolean + /** Null where the server offers no sign-in, which is most of them. */ + readonly auth: AuthDescriptor | null +} + +export interface AuthDescriptor { + /** The device authorization grant, for a client with no browser of its own. */ + readonly device: DeviceAuthDescriptor +} + +export interface DeviceAuthDescriptor { + /** Where to ask for a code pair. */ + readonly authorizeUrl: string + /** Where to poll for the token. */ + readonly tokenUrl: string + /** Where to throw the token away again. */ + readonly revokeUrl: string | null + /** Where a person takes the code, opened in their own browser. */ + readonly verificationUrl: string + /** Seconds the server asks the client to wait between polls. */ + readonly interval: number +} + +export const DEFAULT_SERVICE_DESCRIPTOR: ServiceDescriptor = { + engineVersion: null, + catalogGated: false, + auth: null +} diff --git a/src/domain/models/StoreAccount.ts b/src/domain/models/StoreAccount.ts new file mode 100644 index 0000000..8183619 --- /dev/null +++ b/src/domain/models/StoreAccount.ts @@ -0,0 +1,28 @@ +/** + * Whether this machine is signed in to one store, and whether it could be. + * + * Two booleans rather than one, because the interesting case is the first being false: + * most catalogs have no sign-in at all, and a client that shows a greyed-out "Sign in" + * on them is telling people about a door that does not exist. + */ +export interface StoreAccount { + readonly signInAvailable: boolean + readonly signedIn: boolean +} + +export const NO_ACCOUNT: StoreAccount = { signInAvailable: false, signedIn: false } + +/** What to show a person while they finish signing in somewhere else. */ +export interface SignInPrompt { + /** Opaque to the window: it is the client's half of the exchange, not the person's. */ + readonly deviceCode: string + /** The short one, shown on screen and typed into a browser. */ + readonly userCode: string + /** Opened in the person's own browser. */ + readonly verificationUrl: string + readonly intervalSeconds: number + readonly expiresInSeconds: number +} + +/** How a sign-in ended. `cancelled` is this side giving up, `denied` is the person. */ +export type SignInOutcome = 'signedIn' | 'denied' | 'expired' | 'cancelled' diff --git a/src/domain/models/WarpEngineVersion.ts b/src/domain/models/WarpEngineVersion.ts index 6c5b8eb..96f49fe 100644 --- a/src/domain/models/WarpEngineVersion.ts +++ b/src/domain/models/WarpEngineVersion.ts @@ -18,7 +18,7 @@ export const WARP_ENGINE_VERSION_HEADER = 'warpengine-version' * compile error until `selectCatalogDialect` says which dialect it gets, which is the * point — a new engine version should not be able to arrive silently. */ -export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4'] as const +export const SUPPORTED_WARP_ENGINE_VERSIONS = ['0.2', '0.3', '0.4', '0.5'] as const export type SupportedWarpEngineVersion = typeof SUPPORTED_WARP_ENGINE_VERSIONS[number] diff --git a/src/domain/ports/CredentialRepository.ts b/src/domain/ports/CredentialRepository.ts new file mode 100644 index 0000000..2927131 --- /dev/null +++ b/src/domain/ports/CredentialRepository.ts @@ -0,0 +1,16 @@ +/** + * Where a store's sign-in token is kept between runs. + * + * One token per store, keyed by store id, because the client serves several stores at + * once and being signed in to one says nothing about the others. + * + * A port rather than a file path because the storage is the host's business: on a + * desktop it is the OS keychain, in a test it is a map. Nothing above this layer knows + * which, and nothing above it should — the token is the one value in this application + * that must not end up somewhere it can be read by looking. + */ +export interface CredentialRepository { + readToken: (storeId: string) => string | null + writeToken: (storeId: string, token: string) => void + clearToken: (storeId: string) => void +} diff --git a/src/domain/ports/StoreCatalogGateway.ts b/src/domain/ports/StoreCatalogGateway.ts index 94f8085..dabcf6f 100644 --- a/src/domain/ports/StoreCatalogGateway.ts +++ b/src/domain/ports/StoreCatalogGateway.ts @@ -1,6 +1,7 @@ import type { CatalogListing } from '../models/CatalogListing' import type { EngineProgressListener } from '../models/EngineProgress' import type { InstalledStore } from '../models/InstalledStore' +import type { SignInPrompt, StoreAccount } from '../models/StoreAccount' import type { StorePaths } from '../models/StorePaths' /** @@ -15,4 +16,20 @@ export interface StoreCatalogGateway { readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise + + /** Whether this store offers a sign-in, and whether we are holding a token for it. */ + readAccount: (store: InstalledStore) => Promise + /** + * Ask the store for a code pair. The *waiting* is not here: polling is a loop with a + * cancel in it, which is orchestration, and orchestration belongs above this port. + */ + requestSignIn: (store: InstalledStore, clientName: string) => Promise + /** One poll. Returns the account once it is answered, or null while it is not. */ + pollSignIn: (store: InstalledStore, deviceCode: string) => Promise + signOut: (store: InstalledStore) => Promise +} + +export interface SignInPollResult { + readonly state: 'pending' | 'approved' | 'denied' | 'expired' + readonly account: StoreAccount } diff --git a/src/infrastructure/electron/SafeStorageCredentialRepository.ts b/src/infrastructure/electron/SafeStorageCredentialRepository.ts new file mode 100644 index 0000000..47bd2d9 --- /dev/null +++ b/src/infrastructure/electron/SafeStorageCredentialRepository.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs' +import path from 'node:path' +import { safeStorage } from 'electron' +import type { CredentialRepository } from '../../domain/ports/CredentialRepository' +import type { ApplicationEnvironment } from '../../domain/ports/ApplicationEnvironment' + +const FILE_NAME = 'credentials.json' + +/** + * Tokens in the OS keychain's own encryption, in the application's data directory. + * + * Not in the store home next to `config.json` and `state.json`: those two are the + * store's public description of itself and its record of what it installed, both + * meant to be read and both copied around when somebody moves a library. A password + * does not belong in either. + * + * `safeStorage` is Electron's wrapper over the platform keychain (Keychain on macOS, + * libsecret on Linux, DPAPI on Windows). Where it is unavailable — a Linux box with no + * secret service — this stores nothing at all rather than falling back to plain text. + * The cost is signing in again next run; the alternative is a readable token on disk + * for somebody who thought it was encrypted. + */ +export class SafeStorageCredentialRepository implements CredentialRepository { + public constructor (private readonly environment: ApplicationEnvironment) {} + + public readToken (storeId: string): string | null { + if (!this.available()) return null + const encoded = this.readAll()[storeId] + if (typeof encoded !== 'string') return null + + try { + return safeStorage.decryptString(Buffer.from(encoded, 'base64')) + } catch { + // A token encrypted under a keychain this machine no longer has. Signing in + // again is the only way through, and an unreadable entry is not worth an error. + return null + } + } + + public writeToken (storeId: string, token: string): void { + if (!this.available()) return + + const all = { ...this.readAll() } + all[storeId] = safeStorage.encryptString(token).toString('base64') + this.writeAll(all) + } + + public clearToken (storeId: string): void { + const all = this.readAll() + if (!(storeId in all)) return + + // Rebuilt without the key rather than deleted from a copy: the linter forbids a + // dynamic delete, and this says the same thing without pretending the object was + // ever mutable. + const remaining = Object.fromEntries( + Object.entries(all).filter(([key]: readonly [string, unknown]): boolean => key !== storeId) + ) + this.writeAll(remaining) + } + + public available (): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch { + return false + } + } + + private readAll (): Record { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(this.filePath(), 'utf8')) + return typeof parsed === 'object' && parsed !== null ? parsed as Record : {} + } catch { + return {} + } + } + + private writeAll (all: Record): void { + try { + const target = this.filePath() + fs.mkdirSync(path.dirname(target), { recursive: true }) + // 0600 as well as the encryption: defence in depth costs one argument here, and + // the file is only ever read by this application. + fs.writeFileSync(target, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 }) + } catch { + // A token that could not be saved means signing in again next run, which is not + // worth stopping the application for. + } + } + + private filePath (): string { + return this.environment.resolveUserDataPath(FILE_NAME) + } +} diff --git a/src/infrastructure/engine/CatalogClient.ts b/src/infrastructure/engine/CatalogClient.ts index 43c9e50..88e0ff4 100644 --- a/src/infrastructure/engine/CatalogClient.ts +++ b/src/infrastructure/engine/CatalogClient.ts @@ -41,15 +41,29 @@ export class CatalogClient { private readonly configuration: StoreConfiguration, private readonly files: StoreFileSystem, private readonly cachePath: string, - private readonly log: (line: string) => void + private readonly log: (line: string) => void, + /** + * The bearer token to send, asked for per request rather than held. + * + * Every call this client makes goes to the catalog's own host, so the credential + * belongs on all of them: the catalog needs it to say what this person owns, and + * the download needs it to be allowed at all. + */ + bearerToken: () => string | null = (): null => null ) { this.http = new StoreHttpClient({ userAgent: `warp-engine-client/${CLIENT_VERSION} (${configuration.store.id})`, timeout: configuration.behavior.timeout, - insecure: configuration.behavior.insecure + insecure: configuration.behavior.insecure, + bearerToken }) } + /** The same HTTP client, for the service descriptor and the sign-in flow. */ + public httpClient (): StoreHttpClient { + return this.http + } + public apiUrl (endpoint: 'catalog' | 'download', parameters?: Readonly>): string { const { baseUrl, api } = this.configuration.store const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}` diff --git a/src/infrastructure/engine/CatalogSurveyor.ts b/src/infrastructure/engine/CatalogSurveyor.ts index 40ceace..ab75aaf 100644 --- a/src/infrastructure/engine/CatalogSurveyor.ts +++ b/src/infrastructure/engine/CatalogSurveyor.ts @@ -134,7 +134,8 @@ export class CatalogSurveyor { author: software.author, imageUrl: software.imageUrl, createdAt: release.createdAt, - mode + mode, + access: entry.access }) } return { games, reasons, unavailable } @@ -172,6 +173,7 @@ function toUnavailable ( ): UnavailableEntry { const software: CatalogSoftware = entry.software return { + access: entry.access, name: software.name, title: software.title, platform: software.platform, diff --git a/src/infrastructure/engine/DeviceSignInClient.ts b/src/infrastructure/engine/DeviceSignInClient.ts new file mode 100644 index 0000000..27fe2b3 --- /dev/null +++ b/src/infrastructure/engine/DeviceSignInClient.ts @@ -0,0 +1,117 @@ +import type { DeviceAuthDescriptor } from '../../domain/models/ServiceDescriptor' +import type { StoreHttpClient } from '../http/StoreHttpClient' +import { asRecord, readNumber, readOptionalString, readString } from '../json/JsonRecord' + +/** What the server said when asked for a code pair. */ +export interface DeviceCodeRequest { + readonly deviceCode: string + /** Short enough to read off this screen and type into a browser. */ + readonly userCode: string + readonly verificationUrl: string + readonly intervalSeconds: number + readonly expiresInSeconds: number +} + +export type DeviceSignInState = 'pending' | 'approved' | 'denied' | 'expired' + +export interface DevicePollResult { + readonly state: DeviceSignInState + /** Present exactly once: on the poll that finds the grant newly approved. */ + readonly token: string | null +} + +/** + * The device authorization grant, client side. + * + * The client has no browser of its own, so it cannot host a login form without asking + * somebody to type a password into a window that is not one. Instead it asks for a pair + * of codes, shows the short one, sends the person to the server's own page, and polls + * with the long one until it is answered. + * + * Every address comes from the service descriptor rather than from here. That is the + * point: this class knows the *shape* of the flow, which is the engine's, and nothing + * about any particular store's addresses. + */ +export class DeviceSignInClient { + public constructor ( + private readonly http: StoreHttpClient, + private readonly device: DeviceAuthDescriptor + ) {} + + public async requestCode (clientName: string): Promise { + const { json } = await this.http.requestJson(this.device.authorizeUrl, { + method: 'POST', + payload: { client_name: clientName } + }) + const record = asRecord(json) + if (record === null) throw new Error('the server did not answer with a device code') + + const deviceCode = readOptionalString(record, 'deviceCode') + const userCode = readOptionalString(record, 'userCode') + if (deviceCode === null || userCode === null) { + throw new Error('the server did not answer with a device code') + } + + return { + deviceCode, + userCode, + verificationUrl: readOptionalString(record, 'verificationUrl') ?? this.device.verificationUrl, + // The server's own pacing wins over the descriptor's: it knows what it can take. + intervalSeconds: Math.max(1, readNumber(record, 'interval', this.device.interval)), + expiresInSeconds: Math.max(1, readNumber(record, 'expiresIn', 600)) + } + } + + public async poll (deviceCode: string): Promise { + // 404 is a real answer here — the grant was swept or never existed — so it is read + // rather than thrown, and reported as expired: from the client's side those are the + // same situation, and both mean start again. + const { json, statusCode } = await this.http.requestJson(this.device.tokenUrl, { + method: 'POST', + payload: { device_code: deviceCode }, + accept: [ 404, 410 ] + }) + if (statusCode !== 200) return { state: 'expired', token: null } + + const record = asRecord(json) + if (record === null) return { state: 'pending', token: null } + + return { + state: toState(readString(record, 'state')), + token: readOptionalString(record, 'token') + } + } + + /** + * Signing out: the token this client carries is revoked at the server. + * + * There is no token argument because there is nowhere to put one — the credential + * rides on the request as a bearer header, from the same supplier every other call + * uses. Best effort on purpose: the token is thrown away locally either way, and a + * server that cannot be reached must not leave somebody stuck signed in. + */ + public async revoke (): Promise { + if (this.device.revokeUrl === null) return false + + try { + const { statusCode } = await this.http.requestJson(this.device.revokeUrl, { + method: 'DELETE', + accept: [ 204, 401 ] + }) + return statusCode === 204 + } catch { + return false + } + } +} + +function toState (value: string): DeviceSignInState { + switch (value) { + case 'approved': + case 'denied': + case 'expired': + return value + default: + return 'pending' + } +} diff --git a/src/infrastructure/engine/NativeStoreCatalogGateway.ts b/src/infrastructure/engine/NativeStoreCatalogGateway.ts index 1250e92..a36dba7 100644 --- a/src/infrastructure/engine/NativeStoreCatalogGateway.ts +++ b/src/infrastructure/engine/NativeStoreCatalogGateway.ts @@ -10,19 +10,24 @@ import type { InstalledStore } from '../../domain/models/InstalledStore' import type { CatalogSurvey, SelectedGame, UnavailableEntry } from '../../domain/models/SelectedGame' +import type { ServiceDescriptor } from '../../domain/models/ServiceDescriptor' +import type { SignInPrompt, StoreAccount } from '../../domain/models/StoreAccount' import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration' import type { StorePaths } from '../../domain/models/StorePaths' -import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' +import type { CredentialRepository } from '../../domain/ports/CredentialRepository' +import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway' import { StoreFileSystem } from '../files/StoreFileSystem' import { CatalogClient, type FetchedCatalog } from './CatalogClient' import { CatalogSurveyor } from './CatalogSurveyor' import type { CatalogEntry } from './dialects/CatalogDialect' import { selectCatalogDialect } from './dialects/CatalogDialectSelector' import { DesktopLayoutResolver } from './DesktopLayoutResolver' +import { DeviceSignInClient } from './DeviceSignInClient' import { GameInstaller } from './GameInstaller' import { HostMachineDetector } from './HostMachineDetector' import { LauncherWriter } from './launchers/LauncherWriter' import { PayloadInstaller } from './PayloadInstaller' +import { ServiceDescriptorClient } from './ServiceDescriptorClient' import { StoreConfigurationReader } from './StoreConfigurationReader' import { StoreStateRepository } from './StoreStateRepository' @@ -45,6 +50,12 @@ const CATALOG_CACHE_FILE_NAME = 'catalog.json' export class NativeStoreCatalogGateway implements StoreCatalogGateway { private readonly hosts = new HostMachineDetector() + /** + * The credentials are injected because they are the host's to keep: on a desktop the + * OS keychain, in the smoke test a map in memory. Nothing here knows which. + */ + public constructor (private readonly credentials: CredentialRepository = NO_CREDENTIALS) {} + public async listGames ( store: InstalledStore, progress: EngineProgressListener = {} @@ -53,7 +64,10 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { const host = this.hosts.findHost() engine.log(`host: ${host.operatingSystem}/${host.architecture}`) - const survey = engine.surveyor.survey(await this.readEntries(engine), host) + const [ descriptor, entries ] = await Promise.all([ + engine.service.fetchDescriptor(), this.readEntries(engine) + ]) + const survey = engine.surveyor.survey(entries, host) const installed = engine.state.readState() // One list, both kinds: a client that hides what it cannot install leaves the @@ -64,7 +78,12 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { ].sort((left: Game, right: Game): number => left.title.toLowerCase().localeCompare(right.title.toLowerCase())) - return { games, skipped: survey.skipped, paths: this.toPaths(engine) } + return { + games, + skipped: survey.skipped, + paths: this.toPaths(engine), + account: toAccount(descriptor, this.credentials.readToken(store.id)) + } } /** @@ -132,6 +151,72 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { return Promise.resolve() } + public async readAccount (store: InstalledStore): Promise { + const engine = this.openStore(store, {}) + const descriptor = await engine.service.fetchDescriptor() + return toAccount(descriptor, this.credentials.readToken(store.id)) + } + + public async requestSignIn (store: InstalledStore, clientName: string): Promise { + const { client } = await this.openSignIn(store) + const requested = await client.requestCode(clientName) + return { + deviceCode: requested.deviceCode, + userCode: requested.userCode, + verificationUrl: requested.verificationUrl, + intervalSeconds: requested.intervalSeconds, + expiresInSeconds: requested.expiresInSeconds + } + } + + /** + * One poll. The token is written here, on the single answer that carries it — a + * caller that had to remember to save it would eventually forget. + */ + public async pollSignIn (store: InstalledStore, deviceCode: string): Promise { + const { client, descriptor, log } = await this.openSignIn(store) + const result = await client.poll(deviceCode) + if (result.state === 'approved' && result.token !== null) { + this.credentials.writeToken(store.id, result.token) + log('signed in') + } + return { + state: result.state, + account: toAccount(descriptor, this.credentials.readToken(store.id)) + } + } + + /** + * Sign out: tell the server, then forget the token locally regardless. + * + * The local half is what matters and must not depend on the network — somebody + * signing out on a train has to actually be signed out. + */ + public async signOut (store: InstalledStore): Promise { + const engine = this.openStore(store, {}) + const descriptor = await engine.service.fetchDescriptor() + if (descriptor.auth !== null && this.credentials.readToken(store.id) !== null) { + await new DeviceSignInClient(engine.catalog.httpClient(), descriptor.auth.device).revoke() + } + this.credentials.clearToken(store.id) + engine.log('signed out') + return toAccount(descriptor, null) + } + + /** The sign-in client for one store, or a clear error if the store offers none. */ + private async openSignIn (store: InstalledStore): Promise { + const engine = this.openStore(store, {}) + const descriptor = await engine.service.fetchDescriptor() + if (descriptor.auth === null) { + throw new Error(`${store.name} does not offer signing in`) + } + return { + client: new DeviceSignInClient(engine.catalog.httpClient(), descriptor.auth.device), + descriptor, + log: engine.log + } + } + /** * Fetch the catalog and read it with the dialect its engine version calls for. * @@ -157,7 +242,8 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { const layouts = new DesktopLayoutResolver(configuration, this.hosts) const layout = layouts.resolveLayout() const catalog = new CatalogClient( - configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log) + configuration, files, path.join(store.home, CATALOG_CACHE_FILE_NAME), log, + (): string | null => this.credentials.readToken(store.id)) const launchers = new LauncherWriter(configuration, layouts, files, log) return { @@ -167,6 +253,7 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { catalog, launchers, log, + service: new ServiceDescriptorClient(catalog.httpClient(), configuration.store.baseUrl, log), surveyor: new CatalogSurveyor(configuration, log), state: new StoreStateRepository(files, path.join(store.home, STATE_FILE_NAME), log), installer: new GameInstaller( @@ -197,10 +284,16 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway { installedVersion: record?.version ?? null, menuEntryPath: record?.menuEntry ?? null, executablePath: record?.executable ?? null, - hostedUrl: game.mode === WEB_MODE ? engine.launchers.webUrl(game) : null, + // The catalog's own play address wins where it gives one: a store that gates its + // web builds serves them from a page that knows how to ask somebody to sign in, + // and the raw /file/ directory under it does not. + hostedUrl: game.mode === WEB_MODE + ? game.access?.webUrl ?? engine.launchers.webUrl(game) + : null, installable: true, unavailableReason: null, - unavailableDetail: null + unavailableDetail: null, + access: game.access } } @@ -226,6 +319,7 @@ interface StoreEngineContext { readonly layout: DesktopLayout readonly layouts: DesktopLayoutResolver readonly catalog: CatalogClient + readonly service: ServiceDescriptorClient readonly launchers: LauncherWriter readonly surveyor: CatalogSurveyor readonly state: StoreStateRepository @@ -258,10 +352,42 @@ function toUnavailableGame (entry: UnavailableEntry): Game { hostedUrl: null, installable: false, unavailableReason: entry.reason, - unavailableDetail: entry.detail + unavailableDetail: entry.detail, + access: entry.access } } +interface SignInContext { + readonly client: DeviceSignInClient + readonly descriptor: ServiceDescriptor + readonly log: (line: string) => void +} + +/** + * Holding a token for a store that has no sign-in is not being signed in. + * + * It happens: a store can lose its identity configuration, or a client can keep a token + * from before. Reporting it as signed in would offer a "sign out" for a door that is no + * longer there. + */ +function toAccount (descriptor: ServiceDescriptor, token: string | null): StoreAccount { + const available = descriptor.auth !== null + return { signInAvailable: available, signedIn: available && token !== null } +} + +/** + * A client with nowhere to keep a token is a client that is never signed in. + * + * The two writers throw nothing away and record nothing: this is the shape the smoke + * test runs in, where there is no Electron and therefore no keychain, and a store with + * no sign-in behaves exactly as it always did. + */ +const NO_CREDENTIALS: CredentialRepository = { + readToken: (): null => null, + writeToken: (storeId: string, token: string): void => { void storeId; void token }, + clearToken: (storeId: string): void => { void storeId } +} + function toMode (mode: string): GameMode { return mode === WEB_MODE ? WEB_MODE : APP_MODE } diff --git a/src/infrastructure/engine/ServiceDescriptorClient.ts b/src/infrastructure/engine/ServiceDescriptorClient.ts new file mode 100644 index 0000000..157e166 --- /dev/null +++ b/src/infrastructure/engine/ServiceDescriptorClient.ts @@ -0,0 +1,89 @@ +import { + DEFAULT_SERVICE_DESCRIPTOR, type AuthDescriptor, type DeviceAuthDescriptor, + type ServiceDescriptor +} from '../../domain/models/ServiceDescriptor' +import { HttpStatusError } from '../http/HttpTextClient' +import type { StoreHttpClient } from '../http/StoreHttpClient' +import { asRecord, readBoolean, readNumber, readOptionalString, readRecord } from '../json/JsonRecord' + +const SERVICE_PATH = '/api/service' + +/** + * `GET /api/service`: what this catalog's server is, asked before anything else. + * + * A missing descriptor is an answer, not a failure. Every WarpEngine before 0.5 has no + * such endpoint, so a 404 means "an older engine" — a plain catalog with nothing gated + * and nobody to sign in as, which is exactly what this client assumed for its whole + * life before now. Same for a network that is simply down: the store still works + * offline from its cached catalog, and refusing to open because we could not ask the + * server about itself would be a worse client than the one we had. + */ +export class ServiceDescriptorClient { + public constructor ( + private readonly http: StoreHttpClient, + private readonly baseUrl: string, + private readonly log: (line: string) => void + ) {} + + public async fetchDescriptor (): Promise { + const url = `${this.baseUrl}${SERVICE_PATH}` + try { + const { json } = await this.http.requestJson(url) + const record = asRecord(json) + if (record === null) return DEFAULT_SERVICE_DESCRIPTOR + + const descriptor: ServiceDescriptor = { + engineVersion: readOptionalString(record, 'version'), + catalogGated: readBoolean(readRecord(record, 'catalog') ?? {}, 'gated', false), + auth: readAuth(record, this.baseUrl) + } + this.log(describe(descriptor)) + return descriptor + } catch (error: unknown) { + if (error instanceof HttpStatusError && error.statusCode === 404) { + this.log('the catalog has no service descriptor — an engine older than 0.5') + } else { + this.log(`warning: could not read ${url} — carrying on as a plain catalog`) + } + return DEFAULT_SERVICE_DESCRIPTOR + } + } +} + +function readAuth (record: Readonly>, baseUrl: string): AuthDescriptor | null { + const auth = readRecord(record, 'auth') + if (auth === null) return null + + const device = readRecord(auth, 'device') + if (device === null) return null + + const authorizeUrl = absolute(readOptionalString(device, 'authorizeUrl'), baseUrl) + const tokenUrl = absolute(readOptionalString(device, 'tokenUrl'), baseUrl) + const verificationUrl = absolute(readOptionalString(device, 'verificationUrl'), baseUrl) + // Two of the three are the flow itself and the third is where a person goes. Without + // all three there is no sign-in to offer, and half a flow is worse than none. + if (authorizeUrl === null || tokenUrl === null || verificationUrl === null) return null + + const descriptor: DeviceAuthDescriptor = { + authorizeUrl, + tokenUrl, + revokeUrl: absolute(readOptionalString(device, 'revokeUrl'), baseUrl), + verificationUrl, + interval: Math.max(1, readNumber(device, 'interval', 5)) + } + return { device: descriptor } +} + +/** A server may answer with a path; it knows its own address better than we do. */ +function absolute (value: string | null, baseUrl: string): string | null { + if (value === null || value.length === 0) return null + if (value.startsWith('http://') || value.startsWith('https://')) return value + return `${baseUrl.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}` +} + +function describe (descriptor: ServiceDescriptor): string { + const version = descriptor.engineVersion ?? 'an unnamed version' + const gated = descriptor.catalogGated ? 'some titles need an entitlement' : 'nothing is gated' + const auth = descriptor.auth === null ? 'no sign-in' : 'sign-in available' + return `catalog served by WarpEngine ${version} — ${gated}, ${auth}` +} diff --git a/src/infrastructure/engine/dialects/AccessAwareCatalogDialect.ts b/src/infrastructure/engine/dialects/AccessAwareCatalogDialect.ts new file mode 100644 index 0000000..9e250b4 --- /dev/null +++ b/src/infrastructure/engine/dialects/AccessAwareCatalogDialect.ts @@ -0,0 +1,57 @@ +import { + readBoolean, readNumber, readOptionalString, readRecord, readString, type JsonRecord +} from '../../json/JsonRecord' +import type { CatalogAccess, CatalogPrice } from '../../../domain/models/CatalogAccess' +import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect' + +/** + * The catalog as WarpEngine 0.5 serves it: the same entries, plus what they cost. + * + * 0.5 is the first engine that can say a title is not yours. Every entry carries an + * `access` block — even in a catalog that gates nothing, so that "this store is open" + * and "this store did not say" stay tellable apart. Everything else about the shape is + * unchanged, which is why this is the older dialect with one field added rather than a + * parser of its own. + * + * The words are the engine's, not any store's. A client reads more than one catalog, + * and a field named after what one shop calls its wares is a field that only works + * there. + */ +export class AccessAwareCatalogDialect extends SoftwareListCatalogDialect { + protected override readAccess (entry: JsonRecord): CatalogAccess | null { + const access = readRecord(entry, 'access') + // An entry with no block at all: possible from a 0.5 engine whose policy failed to + // answer. Reading it as "open" would be inventing the friendlier of two answers. + if (access === null) return null + + return { + gated: readBoolean(access, 'gated', false), + entitled: readNullableBoolean(access, 'entitled'), + price: readPrice(access), + purchaseUrl: readOptionalString(access, 'purchaseUrl'), + webUrl: readOptionalString(access, 'webUrl') + } + } +} + +/** + * Three states, not two: yes, no, and nobody asked. + * + * A client that is not signed in gets null, and that is the case worth keeping + * separate — it is the difference between "you do not own this" and "there is no you", + * and only the second is a reason to offer signing in. + */ +function readNullableBoolean (record: JsonRecord, key: string): boolean | null { + const value = record[key] + return typeof value === 'boolean' ? value : null +} + +/** A price with no currency is not a price anybody can be shown. */ +function readPrice (access: JsonRecord): CatalogPrice | null { + const price = readRecord(access, 'price') + if (price === null) return null + + const currency = readString(price, 'currency') + if (currency.length === 0) return null + return { amountCents: readNumber(price, 'amountCents'), currency } +} diff --git a/src/infrastructure/engine/dialects/CatalogDialect.ts b/src/infrastructure/engine/dialects/CatalogDialect.ts index f2f528e..eae3468 100644 --- a/src/infrastructure/engine/dialects/CatalogDialect.ts +++ b/src/infrastructure/engine/dialects/CatalogDialect.ts @@ -1,3 +1,4 @@ +import type { CatalogAccess } from '../../../domain/models/CatalogAccess' import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion' /** @@ -17,6 +18,15 @@ export interface CatalogDialect { export interface CatalogEntry { readonly software: CatalogSoftware + /** + * What the catalog says about getting this title, or null where it says nothing. + * + * Null is not "free": it is an engine too old to have an opinion, and a store that + * never gated anything reads the same as one that could not say. Both mean the same + * thing in practice — try the download — but only one of them is worth offering a + * sign-in for. + */ + readonly access: CatalogAccess | null /** * The release the catalog itself calls newest-and-stable, or null when it names none. * diff --git a/src/infrastructure/engine/dialects/CatalogDialectSelector.ts b/src/infrastructure/engine/dialects/CatalogDialectSelector.ts index e7ef25c..6431505 100644 --- a/src/infrastructure/engine/dialects/CatalogDialectSelector.ts +++ b/src/infrastructure/engine/dialects/CatalogDialectSelector.ts @@ -1,4 +1,5 @@ import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngineVersion' +import { AccessAwareCatalogDialect } from './AccessAwareCatalogDialect' import type { CatalogDialect } from './CatalogDialect' import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect' @@ -7,9 +8,11 @@ import { SoftwareListCatalogDialect } from './SoftwareListCatalogDialect' * * The switch is exhaustive over `SUPPORTED_WARP_ENGINE_VERSIONS`, which is the whole * mechanism: adding a version to that list stops compiling here until somebody decides - * what it reads like. Three versions share one dialect today because the catalog's - * shape has not changed across them — and one class serving three versions is the - * honest way to say that, rather than three identical ones pretending otherwise. + * what it reads like. Three versions share one dialect because the catalog's shape did + * not change across them — and one class serving three versions is the honest way to + * say that, rather than three identical ones pretending otherwise. + * + * 0.5 gets its own, because that is the engine that started saying what a title costs. */ export function selectCatalogDialect (version: SupportedWarpEngineVersion): CatalogDialect { switch (version) { @@ -17,5 +20,7 @@ export function selectCatalogDialect (version: SupportedWarpEngineVersion): Cata case '0.3': case '0.4': return new SoftwareListCatalogDialect(version) + case '0.5': + return new AccessAwareCatalogDialect(version) } } diff --git a/src/infrastructure/engine/dialects/SoftwareListCatalogDialect.ts b/src/infrastructure/engine/dialects/SoftwareListCatalogDialect.ts index 880384c..11b9c17 100644 --- a/src/infrastructure/engine/dialects/SoftwareListCatalogDialect.ts +++ b/src/infrastructure/engine/dialects/SoftwareListCatalogDialect.ts @@ -2,6 +2,7 @@ import type { SupportedWarpEngineVersion } from '../../../domain/models/WarpEngi import { asRecord, readOptionalString, readRecord, readString, type JsonRecord } from '../../json/JsonRecord' +import type { CatalogAccess } from '../../../domain/models/CatalogAccess' import type { CatalogAsset, CatalogDialect, CatalogEntry, CatalogRelease, CatalogSoftware } from './CatalogDialect' @@ -35,6 +36,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect { if (software === null) continue found.push({ software, + access: this.readAccess(entry), latestRelease: this.readLatestRelease(entry), releaseCandidates: this.readCandidates(entry) }) @@ -42,8 +44,20 @@ export class SoftwareListCatalogDialect implements CatalogDialect { return found } + /** + * What the catalog says about getting this title. Nothing, at these versions. + * + * An engine older than 0.5 has no opinion to report, and inventing one here would be + * worse than admitting it: "not gated" and "could not say" are different answers, and + * only the first is safe to act on. The subclass that can read it overrides this. + */ + protected readAccess (entry: JsonRecord): CatalogAccess | null { + void entry + return null + } + /** A title with no name is not a title: nothing could be keyed by it. */ - private readSoftware (entry: JsonRecord): CatalogSoftware | null { + protected readSoftware (entry: JsonRecord): CatalogSoftware | null { const software = readRecord(entry, 'software') if (software === null) return null const name = readOptionalString(software, 'name') @@ -59,7 +73,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect { } } - private readLatestRelease (entry: JsonRecord): CatalogRelease | null { + protected readLatestRelease (entry: JsonRecord): CatalogRelease | null { const latest = readRecord(entry, 'latestRelease') return latest === null ? null : this.readRelease(latest) } @@ -70,7 +84,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect { * `releases` arrives newest-first from the API and `latestRelease` is usually its * first element, so identity is settled on the release's own id where it has one. */ - private readCandidates (entry: JsonRecord): readonly CatalogRelease[] { + protected readCandidates (entry: JsonRecord): readonly CatalogRelease[] { const records: JsonRecord[] = [] const latest = readRecord(entry, 'latestRelease') if (latest !== null) records.push(latest) @@ -93,7 +107,7 @@ export class SoftwareListCatalogDialect implements CatalogDialect { return candidates } - private readRelease (release: JsonRecord): CatalogRelease { + protected readRelease (release: JsonRecord): CatalogRelease { const assets: CatalogAsset[] = [] const listed = release['assets'] if (Array.isArray(listed)) { diff --git a/src/infrastructure/http/StoreHttpClient.ts b/src/infrastructure/http/StoreHttpClient.ts index eee7d8a..3cb78bd 100644 --- a/src/infrastructure/http/StoreHttpClient.ts +++ b/src/infrastructure/http/StoreHttpClient.ts @@ -12,6 +12,7 @@ export interface HttpResponseBody { readonly contentType: string /** Lower-cased names, as Node delivers them. The engine version arrives in one. */ readonly headers: Readonly> + readonly statusCode: number } export interface StoreHttpOptions { @@ -19,6 +20,22 @@ export interface StoreHttpOptions { /** Seconds, as the store config states it. */ readonly timeout: number readonly insecure: boolean + /** + * The bearer token to send, asked for per request. + * + * A function rather than a value because the token changes under a long-lived + * client — signing in and out do not rebuild it — and because there is no reason + * to hold the secret in a field that outlives the request that needs it. + */ + readonly bearerToken?: () => string | null +} + +interface RequestOptions { + readonly method?: string + readonly body?: string + readonly contentType?: string + /** Statuses to hand back rather than throw on. */ + readonly accept?: readonly number[] } /** @@ -33,8 +50,8 @@ export interface StoreHttpOptions { export class StoreHttpClient { public constructor (private readonly options: StoreHttpOptions) {} - public async readBytes (url: string): Promise { - return await this.request(url, MAX_REDIRECTS, async ( + public async readBytes (url: string, request: RequestOptions = {}): Promise { + return await this.request(url, MAX_REDIRECTS, request, async ( response: http.IncomingMessage ): Promise => { const chunks: Buffer[] = [] @@ -42,11 +59,31 @@ export class StoreHttpClient { return { body: Buffer.concat(chunks), contentType: response.headers['content-type'] ?? '', - headers: readHeaders(response) + headers: readHeaders(response), + statusCode: response.statusCode ?? 0 } }) } + /** A JSON request and a JSON answer — the shape every auth endpoint speaks. */ + public async requestJson ( + url: string, + request: RequestOptions & { readonly payload?: unknown } = {} + ): Promise<{ readonly json: unknown, readonly statusCode: number }> { + const { payload, ...rest } = request + const response = await this.readBytes(url, { + ...rest, + ...(payload === undefined + ? {} + : { body: JSON.stringify(payload), contentType: 'application/json' }) + }) + const text = response.body.toString('utf8') + return { + json: text.trim().length === 0 ? null : JSON.parse(text), + statusCode: response.statusCode + } + } + /** * Stream `url` into `destination` atomically. Returns bytes written. * @@ -60,7 +97,7 @@ export class StoreHttpClient { let written = 0 try { - await this.request(url, MAX_REDIRECTS, async (response: http.IncomingMessage): Promise => { + await this.request(url, MAX_REDIRECTS, {}, async (response: http.IncomingMessage): Promise => { response.on('data', (chunk: Buffer): void => { written += chunk.length }) await pipeline(response, fs.createWriteStream(partial)) }) @@ -76,42 +113,93 @@ export class StoreHttpClient { private async request ( url: string, redirectsLeft: number, - consume: (response: http.IncomingMessage) => Promise + request: RequestOptions, + consume: (response: http.IncomingMessage) => Promise, + origin: string = originOf(url) ): Promise { - const response = await this.open(url) + const response = await this.open(url, request, origin) const status = response.statusCode ?? 0 const location = response.headers.location if (status >= 300 && status < 400 && location !== undefined) { response.resume() if (redirectsLeft <= 0) throw new Error(`too many redirects for ${url}`) - return await this.request(new URL(location, url).toString(), redirectsLeft - 1, consume) + const next = new URL(location, url).toString() + // The origin travels with the redirect chain, not with each hop: a gated + // download answers 302 to a signed storage URL, and *that* host must not be + // sent our bearer token. It is somebody else's server, and a presigned URL is + // refused outright by some object stores when an Authorization header rides + // along with the signature. A redirect back to the catalog keeps the token, + // because that is the server that issued it. + return await this.request(next, redirectsLeft - 1, redirectedRequest(request), consume, origin) } - if (status !== 200) { + if (status !== 200 && !(request.accept ?? []).includes(status)) { response.resume() throw new HttpStatusError(url, status) } return await consume(response) } - private async open (url: string): Promise { + private async open ( + url: string, + request: RequestOptions, + origin: string + ): Promise { return new Promise(( resolve: (response: http.IncomingMessage) => void, reject: (error: Error) => void ): void => { const secure = !url.startsWith('http://') const client = secure ? https : http - const request = client.get(url, { - headers: { 'User-Agent': this.options.userAgent }, + const outgoing = client.request(url, { + method: request.method ?? 'GET', + headers: this.buildHeaders(url, request, origin), ...(secure && this.options.insecure ? { rejectUnauthorized: false } : {}) }, resolve) - request.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => { - request.destroy(new Error(`${url} timed out`)) + outgoing.setTimeout(Math.max(1, this.options.timeout) * 1000, (): void => { + outgoing.destroy(new Error(`${url} timed out`)) }) - request.on('error', reject) + outgoing.on('error', reject) + if (request.body !== undefined) outgoing.write(request.body) + outgoing.end() }) } + + private buildHeaders ( + url: string, + request: RequestOptions, + origin: string + ): Record { + const headers: Record = { 'User-Agent': this.options.userAgent } + if (request.contentType !== undefined) headers['Content-Type'] = request.contentType + if (request.body !== undefined) { + headers['Content-Length'] = String(Buffer.byteLength(request.body)) + } + + const token = originOf(url) === origin ? this.options.bearerToken?.() ?? null : null + if (token !== null && token.length > 0) headers['Authorization'] = `Bearer ${token}` + return headers + } +} + +/** + * A redirect is followed as a GET without the body. + * + * That is what every client does with 301/302 after a POST, and what the servers + * answering them expect. `accept` travels on, because it describes what the caller + * is willing to read rather than anything about one hop. + */ +function redirectedRequest (request: RequestOptions): RequestOptions { + return request.accept === undefined ? {} : { accept: request.accept } +} + +function originOf (url: string): string { + try { + return new URL(url).origin + } catch { + return '' + } } /** diff --git a/src/main/composition/ServiceContainer.ts b/src/main/composition/ServiceContainer.ts index 3ae1cb3..ef49c80 100644 --- a/src/main/composition/ServiceContainer.ts +++ b/src/main/composition/ServiceContainer.ts @@ -1,4 +1,5 @@ import type { App, IpcMain, Shell } from 'electron' +import { AccountService } from '../../application/services/AccountService' import { ApplicationStateService } from '../../application/services/ApplicationStateService' import { CatalogService } from '../../application/services/CatalogService' import { GameLaunchService } from '../../application/services/GameLaunchService' @@ -7,12 +8,14 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis import { StoreSelectionService } from '../../application/services/StoreSelectionService' import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment' import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher' +import { SafeStorageCredentialRepository } from '../../infrastructure/electron/SafeStorageCredentialRepository' import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway' import { HttpTextClient } from '../../infrastructure/http/HttpTextClient' import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository' import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller' import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository' import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository' +import { AccountIpcController } from '../ipc/AccountIpcController' import { AppIpcController } from '../ipc/AppIpcController' import { CatalogIpcController } from '../ipc/CatalogIpcController' import { IpcRouter } from '../ipc/IpcRouter' @@ -35,6 +38,7 @@ export class ServiceContainer { public readonly provisioning: StoreProvisioningService public readonly state: ApplicationStateService public readonly launching: GameLaunchService + public readonly accounts: AccountService private readonly controllers: readonly { register: (router: IpcRouter) => void }[] @@ -46,7 +50,8 @@ export class ServiceContainer { const httpClient = new HttpTextClient() const stores = new FileSystemInstalledStoreRepository() - const catalogGateway = new NativeStoreCatalogGateway() + const credentials = new SafeStorageCredentialRepository(environment) + const catalogGateway = new NativeStoreCatalogGateway(credentials) const registry = new HttpStoreRegistryRepository(httpClient) const installer = new NativeStoreEngineInstaller() const preferencesRepository = new JsonFilePreferencesRepository(environment) @@ -54,6 +59,7 @@ export class ServiceContainer { const preferences = new PreferencesService(preferencesRepository, environment) this.selection = new StoreSelectionService(stores, preferences) this.catalog = new CatalogService(catalogGateway, this.selection) + this.accounts = new AccountService(catalogGateway, this.selection) this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection) this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog) this.state = new ApplicationStateService( @@ -63,7 +69,8 @@ export class ServiceContainer { this.controllers = [ new AppIpcController(this.state, preferences, this.launching), new CatalogIpcController(this.catalog, this.launching, this.guard, this.streams), - new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams) + new StoreIpcController(this.provisioning, this.selection, this.guard, this.streams), + new AccountIpcController(this.accounts, this.streams) ] } diff --git a/src/main/ipc/AccountIpcController.ts b/src/main/ipc/AccountIpcController.ts new file mode 100644 index 0000000..7ca37e4 --- /dev/null +++ b/src/main/ipc/AccountIpcController.ts @@ -0,0 +1,80 @@ +import os from 'node:os' +import type { AccountService, SignInResult } from '../../application/services/AccountService' +import type { StoreAccount } from '../../domain/models/StoreAccount' +import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto' +import type { WindowStreamBroadcaster } from '../streams/WindowStreamBroadcaster' +import type { IpcRouter } from './IpcRouter' + +/** + * Signing in and out. + * + * Deliberately outside the single-flight guard: signing in takes as long as somebody + * takes to find their browser, and holding the store busy for that would stop them + * doing anything else meanwhile. Nothing here writes to the library. + * + * `beginSignIn` answers with the code as soon as there is one and lets the waiting run + * on; the end arrives on the sign-in stream. A reply that only came back minutes later + * would be a request the window had to keep alive for no reason. + */ +export class AccountIpcController { + public constructor ( + private readonly accounts: AccountService, + private readonly streams: WindowStreamBroadcaster + ) {} + + public register (router: IpcRouter): void { + router.handle(IPC_CHANNELS.accountRead, async (): Promise => + toDto(await this.accounts.readAccount())) + router.handle(IPC_CHANNELS.accountBeginSignIn, async (): Promise => + this.handleBeginSignIn()) + router.handle(IPC_CHANNELS.accountCancelSignIn, (): Promise => { + this.accounts.cancelSignIn() + return Promise.resolve() + }) + router.handle(IPC_CHANNELS.accountSignOut, async (): Promise => + toDto(await this.accounts.signOut())) + } + + private async handleBeginSignIn (): Promise { + const session = await this.accounts.beginSignIn(clientName()) + + session.finished.then((result: SignInResult): void => { + this.streams.publishSignInFinished({ + outcome: result.outcome, account: toDto(result.account) + }) + }, (error: unknown): void => { + // A sign-in that fell over is a sign-in that did not happen; the window needs to + // stop showing a code either way. + this.streams.publishSignInFinished({ + outcome: 'expired', account: { signInAvailable: true, signedIn: false } + }) + this.streams.publishLog(`sign-in failed: ${describe(error)}`) + }) + + return { + userCode: session.prompt.userCode, + verificationUrl: session.prompt.verificationUrl, + expiresInSeconds: session.prompt.expiresInSeconds + } + } +} + +function toDto (account: StoreAccount): AccountDto { + return { signInAvailable: account.signInAvailable, signedIn: account.signedIn } +} + +/** + * What this device calls itself on the person's account page. + * + * The machine's own name, because that is what somebody looking at a list of signed-in + * devices needs in order to recognise which one to remove. + */ +function clientName (): string { + const hostname = os.hostname() + return hostname.length > 0 ? `WarpEngine Client (${hostname})` : 'WarpEngine Client' +} + +function describe (error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/ipc/CatalogIpcController.ts b/src/main/ipc/CatalogIpcController.ts index 0ccde9e..2133acb 100644 --- a/src/main/ipc/CatalogIpcController.ts +++ b/src/main/ipc/CatalogIpcController.ts @@ -46,7 +46,11 @@ export class CatalogIpcController { return { games: this.gameMapper.toDtoList(listing.games, baseUrl), skipped: listing.skipped, - paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths) + paths: listing.paths === null ? null : this.pathsMapper.toDto(listing.paths), + account: { + signInAvailable: listing.account.signInAvailable, + signedIn: listing.account.signedIn + } } }) } diff --git a/src/main/streams/WindowStreamBroadcaster.ts b/src/main/streams/WindowStreamBroadcaster.ts index 567d102..10ebed2 100644 --- a/src/main/streams/WindowStreamBroadcaster.ts +++ b/src/main/streams/WindowStreamBroadcaster.ts @@ -1,10 +1,12 @@ import type { BrowserWindow } from 'electron' import type { EngineProgressListener } from '../../domain/models/EngineProgress' import { IPC_CHANNELS } from '../../shared/contracts/IpcChannels' +import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto' import type { SyncEventDto } from '../../shared/contracts/dto/SyncEventDto' /** - * The three one-way streams to the window: log lines, progress events, busy state. + * The one-way streams to the window: log lines, progress events, busy state, and how a + * sign-in ended. * * Holds no window of its own — the reference is handed in when one exists and * cleared when it does not, so a stream that outlives the window is a no-op rather @@ -33,6 +35,14 @@ export class WindowStreamBroadcaster { this.send(IPC_CHANNELS.streamBusyChanged, busy) } + /** + * A sign-in finishes minutes after the call that started it returned, and in another + * window entirely — so it arrives as an event rather than as a reply. + */ + public publishSignInFinished (result: SignInFinishedDto): void { + this.send(IPC_CHANNELS.streamSignInFinished, result) + } + /** A progress listener wired to these streams, for handing to the engine. */ public asProgressListener (): EngineProgressListener { return { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index c7ee52e..6637aca 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -3,6 +3,9 @@ import { BRIDGE_GLOBAL_NAME, type BridgeApi, type StreamListener } from '../shared/contracts/BridgeApi' import { IPC_CHANNELS } from '../shared/contracts/IpcChannels' +import type { + AccountDto, SignInFinishedDto, SignInPromptDto +} from '../shared/contracts/dto/AccountDto' import type { AppStateDto } from '../shared/contracts/dto/AppStateDto' import type { CatalogListingDto } from '../shared/contracts/dto/CatalogListingDto' import type { InstalledStoreDto } from '../shared/contracts/dto/InstalledStoreDto' @@ -40,6 +43,15 @@ const bridge: BridgeApi = { launchGame: async (name: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise, + readAccount: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.accountRead) as Promise, + beginSignIn: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.accountBeginSignIn) as Promise, + cancelSignIn: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.accountCancelSignIn) as Promise, + signOut: async (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.accountSignOut) as Promise, + listRegistryStores: async (): Promise => ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise, installStore: async (store: RegistryStoreDto): Promise => @@ -66,6 +78,12 @@ const bridge: BridgeApi = { ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => { listener(busy) }) + }, + onSignInFinished: (listener: StreamListener): void => { + ipcRenderer.on( + IPC_CHANNELS.streamSignInFinished, + (_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) } + ) } } diff --git a/src/renderer/RendererApplication.ts b/src/renderer/RendererApplication.ts index 6d0a926..6a86e22 100644 --- a/src/renderer/RendererApplication.ts +++ b/src/renderer/RendererApplication.ts @@ -1,5 +1,6 @@ import type { BridgeApi } from '../shared/contracts/BridgeApi' import { requireBridge } from './BridgeAccess' +import { AccountController } from './controllers/AccountController' import { CatalogController } from './controllers/CatalogController' import { EngineStreamController } from './controllers/EngineStreamController' import { PreferencesController } from './controllers/PreferencesController' @@ -11,6 +12,7 @@ import { GameCardView } from './views/GameCardView' import { GateView } from './views/GateView' import { LogDrawerView } from './views/LogDrawerView' import { SideMenuView } from './views/SideMenuView' +import { SignInView } from './views/SignInView' import { TopBarView } from './views/TopBarView' /** @@ -28,10 +30,12 @@ export class RendererApplication { private readonly grid: CatalogGridView private readonly topBar: TopBarView private readonly sideMenu: SideMenuView + private readonly signInPanel: SignInView private readonly catalog: CatalogController private readonly stores: StoreController private readonly preferences: PreferencesController private readonly streams: EngineStreamController + private readonly accounts: AccountController public constructor (bridge: BridgeApi = requireBridge()) { this.bridge = bridge @@ -44,6 +48,10 @@ export class RendererApplication { this.stores = new StoreController(this.bridge, this.store, this.log, this.catalog) this.preferences = new PreferencesController(this.bridge, this.store) this.streams = new EngineStreamController(this.bridge, this.store, this.log) + this.accounts = new AccountController( + this.bridge, this.store, this.log, + async (): Promise => { await this.catalog.refresh() } + ) this.grid = new CatalogGridView(new GameCardView({ onInstall: (name: string): void => { void this.catalog.syncGames([name]) }, @@ -51,8 +59,14 @@ export class RendererApplication { // now has for it, and the engine replaces the old payload and menu entry. onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) }, onLaunch: (name: string): void => { void this.catalog.launchGame(name) }, - onRemove: (name: string): void => { void this.catalog.removeGame(name) } + onRemove: (name: string): void => { void this.catalog.removeGame(name) }, + onPurchase: (name: string): void => { void this.purchase(name) }, + onSignIn: (): void => { void this.accounts.signIn() } })) + this.signInPanel = new SignInView({ + onOpenPage: (): void => { void this.accounts.openVerificationPage() }, + onCancel: (): void => { void this.accounts.cancelSignIn() } + }) this.topBar = new TopBarView({ onToggleNavigation: (): void => { void this.preferences.toggleNavigation() } }) @@ -61,11 +75,29 @@ export class RendererApplication { onAddStore: (): void => { void this.stores.offerStores() }, onRefresh: (): void => { void this.catalog.refresh() }, onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) }, - onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) } + onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }, + onSignIn: (): void => { void this.accounts.signIn() }, + onSignOut: (): void => { void this.accounts.signOut() } }) this.store.subscribe((state: AppState): void => { this.render(state) }) this.streams.subscribe() + this.accounts.subscribe() + } + + /** + * Buying happens in a browser. + * + * A checkout rebuilt in this window would be a second place to get card handling + * wrong, and the store's own pages already do it. What this side owes afterwards is + * a refresh, which the Refresh button is for. + */ + private async purchase (name: string): Promise { + const url = this.store.readState().games + .find((candidate): boolean => candidate.name === name)?.purchaseUrl ?? null + if (url === null) return + + await this.bridge.openUrl(url) } /** Decides what the window is showing, then hands over to the views. */ @@ -91,6 +123,7 @@ export class RendererApplication { } document.body.classList.toggle('nav-closed', !state.navigationOpen) + this.signInPanel.render(state) this.topBar.render(state) this.sideMenu.render(state) this.log.render(state) diff --git a/src/renderer/controllers/AccountController.ts b/src/renderer/controllers/AccountController.ts new file mode 100644 index 0000000..d857370 --- /dev/null +++ b/src/renderer/controllers/AccountController.ts @@ -0,0 +1,85 @@ +import type { BridgeApi } from '../../shared/contracts/BridgeApi' +import type { SignInFinishedDto } from '../../shared/contracts/dto/AccountDto' +import type { AppStore } from '../state/AppStore' +import type { LogDrawerView } from '../views/LogDrawerView' + +/** + * Signing in and out, from the window's side. + * + * Two halves that do not meet: `signIn` puts a code on screen and returns, and the + * answer arrives later on the sign-in stream — because the person is not here while it + * happens, they are in a browser. Nothing waits on anything. + * + * A sign-in that succeeds refreshes the catalog rather than patching the cards, since + * every entitlement in the listing was read without a credential and is now stale. + */ +export class AccountController { + public constructor ( + private readonly bridge: BridgeApi, + private readonly store: AppStore, + private readonly log: LogDrawerView, + private readonly onSignedIn: () => Promise + ) {} + + public subscribe (): void { + this.bridge.onSignInFinished((result: SignInFinishedDto): void => { + this.store.applySignIn(null) + this.store.applyAccount(result.account) + this.log.appendLine(this.describeOutcome(result)) + // Only a successful sign-in changes what the catalog would say. The other three + // leave it exactly as it was, and re-reading it would be a pointless wait. + if (result.outcome === 'signedIn') void this.onSignedIn() + }) + } + + public async signIn (): Promise { + try { + const prompt = await this.bridge.beginSignIn() + this.store.applySignIn(prompt) + // Opened for them rather than waiting to be clicked: the browser is where the + // rest of this happens, and the code on screen is no use until it is open. + await this.bridge.openUrl(prompt.verificationUrl) + } catch (error: unknown) { + this.store.applySignIn(null) + this.log.appendLine(`${this.store.readState().messages.signInFailed} ${describe(error)}`) + } + } + + /** Re-open the page for somebody who closed the tab before typing the code. */ + public async openVerificationPage (): Promise { + const prompt = this.store.readState().signIn + if (prompt === null) return + + await this.bridge.openUrl(prompt.verificationUrl) + } + + public async cancelSignIn (): Promise { + this.store.applySignIn(null) + await this.bridge.cancelSignIn() + } + + public async signOut (): Promise { + try { + this.store.applyAccount(await this.bridge.signOut()) + // The listing was read as somebody; it has to be read again as nobody, or every + // owned title keeps its Install button until the next refresh. + await this.onSignedIn() + } catch (error: unknown) { + this.log.appendLine(describe(error)) + } + } + + private describeOutcome (result: SignInFinishedDto): string { + const messages = this.store.readState().messages + switch (result.outcome) { + case 'signedIn': return messages.signInDone + case 'denied': return messages.signInDenied + case 'expired': return messages.signInExpired + case 'cancelled': return messages.signInCancelled + } + } +} + +function describe (error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/renderer/controllers/CatalogController.ts b/src/renderer/controllers/CatalogController.ts index 2b51785..c544cef 100644 --- a/src/renderer/controllers/CatalogController.ts +++ b/src/renderer/controllers/CatalogController.ts @@ -19,7 +19,7 @@ export class CatalogController { public async refresh (): Promise { try { const listing: CatalogListingDto = await this.bridge.listGames() - this.store.applyCatalog(listing.games, listing.paths) + this.store.applyCatalog(listing.games, listing.paths, listing.account) for (const reason of listing.skipped) this.log.appendLine(`skipped ${reason}`) } catch (error: unknown) { this.reportFailure(error) diff --git a/src/renderer/index.html b/src/renderer/index.html index 39e3978..7581a0b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -36,6 +36,17 @@ + + +

@@ -74,6 +85,22 @@
+ + +