A catalog that can say a title is not yours
ci/woodpecker/push/woodpecker Pipeline was successful

A store with paid titles had nothing to tell this client and no way for it to
listen: the catalog carried no price, no entitlement and no sign-in, so a gated
download could only come back 403 and leave the window guessing why.

The knowledge belongs on the server, not here. This client serves whichever
catalog a registry names, so anything it knew about a particular shop would be
a rule that breaks every other one. WarpEngine 0.5 answers GET /api/service with
what it offers and puts an `access` block on every entry; this reads both. There
is no store name anywhere in the diff.

- **0.5 is a dialect of its own**, the older shape with `access` added. The
  version list is exhaustive over the selector, so adding it was a compile error
  until somebody said what it reads like — which is what that switch is for.
- **A card shows a price and a Buy button** when a title is not yours, opening
  the store's own page. Buying stays in a browser: a checkout rebuilt here would
  be a second place to get card handling wrong.
- **Signing in is the device grant**: a short code, the person's own browser, and
  no password crossing this window. The token goes in the OS keychain through
  safeStorage — one per store — and where no keychain exists it is not stored at
  all rather than written out in the clear.
- **Owned / To buy** join the categories, since owning something is not the same
  as having installed it.

Three things worth stating about the shape:

The bearer token stops at the origin that issued it. A gated download redirects
to signed storage — often somebody else's host — and some object stores refuse a
request outright when an Authorization header arrives alongside the signature.

An absent access block is not "free". It is an engine too old to have an
opinion, and only one of those two is a reason to offer somebody a sign-in, so
the three states are kept apart all the way to the card.

state.json does not carry entitlement. Whether somebody may download a title is
the server's answer to a question asked now; a copy on disk would go stale on the
next purchase or refund, and a stale yes is the dangerous direction.

A store with no sign-in shows none, and every WarpEngine before 0.5 is such a
store: no Account block, no prices, no new categories. The smoke test against the
live catalog reports exactly that — `sign-in: not offered`, `access: open:13`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:03:54 +02:00
co-authored by Claude Opus 5
parent e35a72336a
commit 26c7aa9be1
51 changed files with 1690 additions and 124 deletions
+42
View File
@@ -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
+28 -66
View File
@@ -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.
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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 <games@teletype.hu>",
+32 -5
View File
@@ -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}`
}
}
+112
View File
@@ -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<SignInResult>
}
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<StoreAccount> {
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<SignInSession> {
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<StoreAccount> {
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<SignInResult> {
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<void> {
await new Promise<void>((resolve: () => void): void => {
setTimeout((): void => { resolve() }, milliseconds)
})
}
+42
View File
@@ -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'
}
+10
View File
@@ -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
}
+10
View File
@@ -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
}
+6 -1
View File
@@ -6,8 +6,13 @@ import type { SelectedGame } from './SelectedGame'
* This is the shape `state.json` carries, keyed `<scope>:<name>`. 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<SelectedGame, 'access'> {
/** The unpacked archive's directory; null for a hosted entry, which has none. */
readonly payload: string | null
readonly executable: string | null
+5
View File
@@ -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. */
+43
View File
@@ -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
}
+28
View File
@@ -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'
+1 -1
View File
@@ -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]
+16
View File
@@ -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
}
+17
View File
@@ -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<StorePaths>
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
/** Whether this store offers a sign-in, and whether we are holding a token for it. */
readAccount: (store: InstalledStore) => Promise<StoreAccount>
/**
* 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<SignInPrompt>
/** One poll. Returns the account once it is answered, or null while it is not. */
pollSignIn: (store: InstalledStore, deviceCode: string) => Promise<SignInPollResult>
signOut: (store: InstalledStore) => Promise<StoreAccount>
}
export interface SignInPollResult {
readonly state: 'pending' | 'approved' | 'denied' | 'expired'
readonly account: StoreAccount
}
@@ -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<string, unknown> {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(this.filePath(), 'utf8'))
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {}
} catch {
return {}
}
}
private writeAll (all: Record<string, unknown>): 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)
}
}
+16 -2
View File
@@ -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<Record<string, string>>): string {
const { baseUrl, api } = this.configuration.store
const url = `${baseUrl}/${api[endpoint].replace(/^\/+/, '')}`
+3 -1
View File
@@ -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,
@@ -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<DeviceCodeRequest> {
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<DevicePollResult> {
// 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<boolean> {
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'
}
}
@@ -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<StoreAccount> {
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<SignInPrompt> {
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<SignInPollResult> {
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<StoreAccount> {
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<SignInContext> {
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
}
@@ -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<ServiceDescriptor> {
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<Record<string, unknown>>, 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}`
}
@@ -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 }
}
@@ -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.
*
@@ -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)
}
}
@@ -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)) {
+102 -14
View File
@@ -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<Record<string, string>>
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<HttpResponseBody> {
return await this.request(url, MAX_REDIRECTS, async (
public async readBytes (url: string, request: RequestOptions = {}): Promise<HttpResponseBody> {
return await this.request(url, MAX_REDIRECTS, request, async (
response: http.IncomingMessage
): Promise<HttpResponseBody> => {
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<void> => {
await this.request(url, MAX_REDIRECTS, {}, async (response: http.IncomingMessage): Promise<void> => {
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<TResult> (
url: string,
redirectsLeft: number,
consume: (response: http.IncomingMessage) => Promise<TResult>
request: RequestOptions,
consume: (response: http.IncomingMessage) => Promise<TResult>,
origin: string = originOf(url)
): Promise<TResult> {
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<http.IncomingMessage> {
private async open (
url: string,
request: RequestOptions,
origin: string
): Promise<http.IncomingMessage> {
return new Promise<http.IncomingMessage>((
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<string, string> {
const headers: Record<string, string> = { '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 ''
}
}
/**
+9 -2
View File
@@ -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)
]
}
+80
View File
@@ -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<AccountDto> =>
toDto(await this.accounts.readAccount()))
router.handle(IPC_CHANNELS.accountBeginSignIn, async (): Promise<SignInPromptDto> =>
this.handleBeginSignIn())
router.handle(IPC_CHANNELS.accountCancelSignIn, (): Promise<void> => {
this.accounts.cancelSignIn()
return Promise.resolve()
})
router.handle(IPC_CHANNELS.accountSignOut, async (): Promise<AccountDto> =>
toDto(await this.accounts.signOut()))
}
private async handleBeginSignIn (): Promise<SignInPromptDto> {
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)
}
+5 -1
View File
@@ -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
}
}
})
}
+11 -1
View File
@@ -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 {
+18
View File
@@ -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<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.catalogLaunchGame, name) as Promise<boolean>,
readAccount: async (): Promise<AccountDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountRead) as Promise<AccountDto>,
beginSignIn: async (): Promise<SignInPromptDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountBeginSignIn) as Promise<SignInPromptDto>,
cancelSignIn: async (): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.accountCancelSignIn) as Promise<void>,
signOut: async (): Promise<AccountDto> =>
ipcRenderer.invoke(IPC_CHANNELS.accountSignOut) as Promise<AccountDto>,
listRegistryStores: async (): Promise<RegistryResultDto> =>
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
@@ -66,6 +78,12 @@ const bridge: BridgeApi = {
ipcRenderer.on(IPC_CHANNELS.streamBusyChanged, (_event: IpcRendererEvent, busy: boolean): void => {
listener(busy)
})
},
onSignInFinished: (listener: StreamListener<SignInFinishedDto>): void => {
ipcRenderer.on(
IPC_CHANNELS.streamSignInFinished,
(_event: IpcRendererEvent, payload: SignInFinishedDto): void => { listener(payload) }
)
}
}
+35 -2
View File
@@ -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<void> => { 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<void> {
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)
@@ -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<void>
) {}
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<void> {
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<void> {
const prompt = this.store.readState().signIn
if (prompt === null) return
await this.bridge.openUrl(prompt.verificationUrl)
}
public async cancelSignIn (): Promise<void> {
this.store.applySignIn(null)
await this.bridge.cancelSignIn()
}
public async signOut (): Promise<void> {
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)
}
@@ -19,7 +19,7 @@ export class CatalogController {
public async refresh (): Promise<void> {
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)
+27
View File
@@ -36,6 +36,17 @@
<button id="add-store" class="btn btn-ghost btn-wide"></button>
</section>
<!--
Signing in, and who is signed in. The whole block is hidden where the catalog
offers no sign-in at all, which is most of them: a greyed-out button is telling
somebody about a door that does not exist.
-->
<section class="side-block" id="account-block" hidden>
<h2 class="side-head" id="head-account"></h2>
<p class="side-quiet-text" id="account-state"></p>
<button id="account-action" class="btn btn-ghost btn-wide"></button>
</section>
<section class="side-block side-cats">
<h2 class="side-head" id="head-cats"></h2>
<nav class="cats" id="cats"></nav>
@@ -74,6 +85,22 @@
</aside>
<div class="content">
<!--
The code to type into a browser. A panel over the grid rather than a screen of
its own: the catalog is still there and still readable, and the sign-in is
something happening elsewhere that this window is only reporting on.
-->
<section id="signin" class="signin" hidden>
<h2 id="signin-title"></h2>
<p id="signin-body"></p>
<p class="signin-code" id="signin-code"></p>
<p class="signin-waiting" id="signin-waiting"></p>
<div class="signin-actions">
<button id="signin-open" class="btn btn-secondary"></button>
<button id="signin-cancel" class="btn btn-ghost"></button>
</div>
</section>
<!-- Shown instead of the grid when there is nothing to drive yet. -->
<section id="gate" class="gate" hidden>
<h1 id="gate-title"></h1>
+36 -4
View File
@@ -1,3 +1,4 @@
import type { AccountDto, SignInPromptDto } from '../../shared/contracts/dto/AccountDto'
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
import type { GameDto } from '../../shared/contracts/dto/GameDto'
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
@@ -31,6 +32,10 @@ export interface AppState {
readonly progress: SyncProgress | null
/** Non-null while the setup screen is up, which is also what hides the grid. */
readonly gate: GatePresentation | null
/** Where this machine stands with the open store. */
readonly account: AccountDto
/** Non-null while a code is on screen waiting to be typed into a browser. */
readonly signIn: SignInPromptDto | null
}
const INITIAL_STATE: AppState = {
@@ -47,7 +52,9 @@ const INITIAL_STATE: AppState = {
filter: ALL_CATEGORIES,
busy: false,
progress: null,
gate: null
gate: null,
account: { signInAvailable: false, signedIn: false },
signIn: null
}
export type AppStateListener = (state: AppState) => void
@@ -96,13 +103,38 @@ export class AppStore {
this.notify()
}
public applyCatalog (games: readonly GameDto[], paths: StorePathsDto | null): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths }
public applyCatalog (
games: readonly GameDto[],
paths: StorePathsDto | null,
account: AccountDto
): void {
this.state = { ...this.state, games, paths: paths ?? this.state.paths, account }
this.notify()
}
public applyAccount (account: AccountDto): void {
this.state = { ...this.state, account }
this.notify()
}
public applySignIn (prompt: SignInPromptDto | null): void {
this.state = { ...this.state, signIn: prompt }
this.notify()
}
public applySelectedStore (store: InstalledStoreDto): void {
this.state = { ...this.state, currentStore: store, games: [], paths: null, filter: ALL_CATEGORIES }
// A new store means a new account: whether we are signed in is per store, and
// carrying the old answer over would show somebody as signed in to a shop they
// have never visited.
this.state = {
...this.state,
currentStore: store,
games: [],
paths: null,
filter: ALL_CATEGORIES,
account: { signInAvailable: false, signedIn: false },
signIn: null
}
this.notify()
}
+10 -2
View File
@@ -46,6 +46,12 @@ function matchesGroup (game: GameDto, group: string): boolean {
return game.updateAvailable
case 'available':
return game.installable && !game.installed
case 'owned':
// Owning something is not the same as having installed it — the point of the
// category is finding what you paid for and have not put on this machine yet.
return game.accessVerdict === 'entitled'
case 'purchasable':
return game.accessVerdict === 'purchasable'
case 'unsupported':
return !game.installable
default:
@@ -57,8 +63,8 @@ function matchesGroup (game: GameDto, group: string): boolean {
* The categories, built from what the catalog actually contains.
*
* There is no genre in a WarpEngine catalog, so the useful axes are the state of a
* title on this machine, the platform it was built with, and whether it runs here or
* in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
* title on this machine, what this person may have of it, the platform it was built
* with, and whether it runs here or in a browser. Empty axes are left out rather than shown as zeroes, and an axis with
* a single value is left out too a filter that changes nothing is noise.
*/
export function buildCategorySections (
@@ -73,6 +79,8 @@ export function buildCategorySections (
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
{ kind: 'group', value: 'owned', label: messages.catOwned, count: count((game: GameDto): boolean => game.accessVerdict === 'entitled') },
{ kind: 'group', value: 'purchasable', label: messages.catPurchasable, count: count((game: GameDto): boolean => game.accessVerdict === 'purchasable') },
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
]
sections.push({
+41
View File
@@ -282,6 +282,34 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
.gate-actions { display: flex; gap: 14px; justify-content: center; align-items: center; flex-wrap: wrap; }
.gate-choice { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-dim); font-size: 13px; }
/* --- signing in --------------------------------------------------------- */
/*
* The code, while somebody carries it to a browser. A band across the top of the
* content rather than a screen of its own: the sign-in is happening elsewhere, and
* there is no reason the catalog should stop being readable while it does.
*/
.signin {
margin: 18px 18px 0;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
text-align: center;
}
.signin h2 { margin: 0 0 6px; font-size: 16px; }
.signin p { margin: 0 0 10px; color: var(--ink-dim); font-size: 13px; }
/* The one thing on screen somebody has to copy by eye, so: large, spaced, and
selectable a code that cannot be highlighted is a code that has to be retyped. */
.signin-code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 28px;
letter-spacing: 0.22em;
color: var(--ink);
user-select: text;
}
.signin-waiting { font-size: 12px; }
.signin-actions { display: flex; gap: 10px; justify-content: center; }
/* --- the grid ----------------------------------------------------------- */
.grid {
flex: 1;
@@ -300,6 +328,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
align-content: start;
}
.empty { margin: auto; color: var(--ink-dim); }
/* A title somebody has not bought is not a broken one: the dimming and the dashed
border belong to what this *machine* cannot do, and there is nothing wrong with
the machine here. */
.card.is-purchasable { opacity: 1; }
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
.gate { overflow-y: auto; }
.card {
@@ -345,6 +378,14 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
}
.badge-app { color: var(--accent); border-color: #2f5a49; }
.badge-web { color: var(--warn); border-color: #5a4a2f; }
/* A price reads as a fact rather than a warning: same weight as the mode badges,
filled rather than outlined, so it is findable while scanning a row of cards. */
.badge-price {
color: var(--ink);
background: var(--panel-2);
border-color: var(--line);
}
.badge-owned { color: var(--accent); border-color: #2f5a49; }
.version { font-size: 12px; color: var(--ink-dim); margin-left: auto; }
.desc {
margin: 0;
+55
View File
@@ -7,6 +7,9 @@ export interface GameCardViewCallbacks {
readonly onUpgrade: (name: string) => void
readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void
/** Opens the catalog's own purchase page in the person's browser. */
readonly onPurchase: (name: string) => void
readonly onSignIn: () => void
}
/**
@@ -25,6 +28,7 @@ export class GameCardView {
const card = createElement('article', 'card')
if (game.installed) card.classList.add('is-installed')
if (!game.installable) card.classList.add('is-unavailable')
if (game.accessVerdict === 'purchasable') card.classList.add('is-purchasable')
card.appendChild(this.createArt(game))
card.appendChild(this.createBody(game, messages, busy))
return card
@@ -75,6 +79,17 @@ export class GameCardView {
meta.appendChild(badge)
}
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
// The price is where the mode badge is rather than down by the button: what a title
// costs is something a person scans a grid for, and a number that only appears
// beside a button is a number they have to hunt for card by card.
if (game.priceLabel !== null && game.accessVerdict !== 'entitled') {
meta.appendChild(createElement('span', 'badge badge-price', game.priceLabel))
}
if (game.accessVerdict === 'entitled') {
const owned = createElement('span', 'badge badge-owned', messages.owned)
owned.title = messages.ownedHint
meta.appendChild(owned)
}
meta.appendChild(this.createVersion(game, messages))
return meta
}
@@ -110,6 +125,19 @@ export class GameCardView {
return actions
}
// Not yours yet: the card sells rather than installs. Deliberately a live button
// and not a dimmed one — the dimmed treatment above is for what this *machine*
// cannot do, and there is nothing wrong with this machine.
if (!game.installed && game.accessVerdict === 'purchasable') {
actions.appendChild(this.createPurchase(game, messages, busy))
return actions
}
// The catalog would know, if it knew who was asking.
if (!game.installed && game.accessVerdict === 'signInRequired') {
actions.appendChild(this.createSignIn(messages, busy))
return actions
}
actions.appendChild(this.createPrimary(game, messages, busy))
// Only an installed title has anything in the menu: nothing to upgrade and nothing
// to uninstall until there is something on the disk.
@@ -117,6 +145,33 @@ export class GameCardView {
return actions
}
/**
* Buy it which happens in a browser, not here.
*
* Payment is the store's business and its own web pages already do it; a checkout
* rebuilt in this window would be a second place to get card handling wrong. After
* buying, Refresh is what turns the card into an Install.
*/
private createPurchase (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const button = createElement('button', 'btn btn-primary btn-purchase')
button.textContent = game.priceLabel === null
? messages.purchase
: `${messages.purchase} · ${game.priceLabel}`
button.disabled = busy || game.purchaseUrl === null
button.title = messages.purchaseHint
button.addEventListener('click', (): void => { this.callbacks.onPurchase(game.name) })
return button
}
private createSignIn (messages: MessageBundle, busy: boolean): HTMLElement {
const button = createElement('button', 'btn btn-secondary')
button.textContent = messages.signInToInstall
button.disabled = busy
button.title = messages.signInToInstallHint
button.addEventListener('click', (): void => { this.callbacks.onSignIn() })
return button
}
/**
* The one action a card leads with.
*
+40
View File
@@ -12,6 +12,8 @@ export interface SideMenuViewCallbacks {
readonly onRefresh: () => void
readonly onSelectCategory: (filter: CategoryFilter) => void
readonly onSelectLocale: (locale: string) => void
readonly onSignIn: () => void
readonly onSignOut: () => void
}
/**
@@ -26,6 +28,10 @@ export class SideMenuView {
private readonly addStore = requireElement('add-store', HTMLButtonElement)
private readonly refresh = requireElement('refresh', HTMLButtonElement)
private readonly categories = requireElement('cats', HTMLElement)
private readonly accountBlock = requireElement('account-block', HTMLElement)
private readonly accountHead = requireElement('head-account', HTMLElement)
private readonly accountState = requireElement('account-state', HTMLElement)
private readonly accountAction = requireElement('account-action', HTMLButtonElement)
private readonly locale = requireElement('locale', HTMLSelectElement)
/** The square around the select: it is what a pointer hovers, so the tooltip is its. */
private readonly localeControl = requireElement('locale-control', HTMLElement)
@@ -34,8 +40,20 @@ export class SideMenuView {
this.addStore.addEventListener('click', callbacks.onAddStore)
this.refresh.addEventListener('click', callbacks.onRefresh)
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
this.accountAction.addEventListener('click', (): void => {
if (this.signedIn) this.callbacks.onSignOut()
else this.callbacks.onSignIn()
})
}
/**
* Which of the two the one button does.
*
* Read at click time rather than rebound on every render: a listener replaced under
* a pointer that is already down is a click that goes nowhere.
*/
private signedIn = false
public render (state: AppState): void {
setText(this.storesHead, state.messages.stores)
setText(this.categoriesHead, state.messages.categories)
@@ -49,6 +67,7 @@ export class SideMenuView {
this.localeControl.title = state.messages.language
this.renderStores(state)
this.renderAccount(state)
this.renderCategories(state)
this.renderLocales(state)
this.renderEnabled(state)
@@ -78,6 +97,27 @@ export class SideMenuView {
}))
}
/**
* The account block, or nothing at all.
*
* Hidden outright where the catalog offers no sign-in which is most of them. A
* disabled "Sign in" would be telling somebody about a door that is not there.
*/
private renderAccount (state: AppState): void {
this.accountBlock.hidden = !state.account.signInAvailable
if (!state.account.signInAvailable) return
this.signedIn = state.account.signedIn
setText(this.accountHead, state.messages.account)
setText(this.accountState, state.account.signedIn ? state.messages.signedIn : '')
setText(this.accountAction, state.account.signedIn
? state.messages.signOut
: state.messages.signIn)
// A sign-in already under way has its own panel with a cancel on it; a second
// "Sign in" here would start a second flow behind the first one's code.
this.accountAction.disabled = state.busy || state.signIn !== null
}
private renderCategories (state: AppState): void {
const sections = buildCategorySections(state.games, state.messages)
const nodes: HTMLElement[] = []
+47
View File
@@ -0,0 +1,47 @@
import { requireElement, setText } from '../dom/Dom'
import type { AppState } from '../state/AppStore'
export interface SignInViewCallbacks {
readonly onOpenPage: () => void
readonly onCancel: () => void
}
/**
* The code, while somebody takes it to a browser.
*
* A panel over the catalog rather than a screen of its own: the sign-in is happening
* somewhere else, and there is no reason this window should stop being useful while it
* does. Everything on it is one of three things the code, a way back to the page for
* whoever closed the tab, and a way out.
*/
export class SignInView {
private readonly panel = requireElement('signin', HTMLElement)
private readonly title = requireElement('signin-title', HTMLElement)
private readonly body = requireElement('signin-body', HTMLElement)
private readonly code = requireElement('signin-code', HTMLElement)
private readonly waiting = requireElement('signin-waiting', HTMLElement)
private readonly openPage = requireElement('signin-open', HTMLButtonElement)
private readonly cancel = requireElement('signin-cancel', HTMLButtonElement)
public constructor (callbacks: SignInViewCallbacks) {
this.openPage.addEventListener('click', callbacks.onOpenPage)
this.cancel.addEventListener('click', callbacks.onCancel)
}
public render (state: AppState): void {
const prompt = state.signIn
if (prompt === null) {
this.panel.hidden = true
return
}
const storeName = state.currentStore?.name ?? state.messages.appName
setText(this.title, state.messages.signInTitle.replace('%{store}', storeName))
setText(this.body, state.messages.signInBody)
setText(this.code, prompt.userCode)
setText(this.waiting, state.messages.signInWaiting)
setText(this.openPage, state.messages.signInOpenAgain)
setText(this.cancel, state.messages.signInCancel)
this.panel.hidden = false
}
}
+34
View File
@@ -123,10 +123,44 @@ class SmokeTest {
}
const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '')
this.reportListing(games)
this.reportAccess(listing, games)
if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`)
}
/**
* What the catalog said about who may have what.
*
* The load-bearing case is the boring one: an older engine says nothing, every title
* comes back `open`, and the client behaves exactly as it did before any of this
* existed. A gated catalog is where the rest of it starts mattering.
*/
private reportAccess (listing: CatalogListing, games: readonly GameDto[]): void {
const account = listing.account
this.reportOk('sign-in', account.signInAvailable
? (account.signedIn ? 'offered, and signed in' : 'offered, not signed in')
: 'not offered by this catalog')
const counts = new Map<string, number>()
for (const game of games) {
counts.set(game.accessVerdict, (counts.get(game.accessVerdict) ?? 0) + 1)
}
const summary = [...counts.entries()]
.map(([verdict, count]: readonly [string, number]): string => `${verdict}:${String(count)}`)
.join(', ')
this.reportOk('access', summary)
// A price with nothing to click, or a purchase button with no price, is a card
// somebody cannot act on.
const unbuyable = games.filter((game: GameDto): boolean =>
game.accessVerdict === 'purchasable' && game.purchaseUrl === null)
if (unbuyable.length > 0) {
this.reportBad('purchase links', `${String(unbuyable.length)} priced titles have nowhere to buy them`)
} else if ((counts.get('purchasable') ?? 0) > 0) {
this.reportOk('purchase links', 'every priced title has one')
}
}
private reportListing (games: readonly GameDto[]): void {
const installable = games.filter((game: GameDto): boolean => game.installable)
const native = installable.filter((game: GameDto): boolean => game.mode === 'app').length
+8
View File
@@ -1,3 +1,4 @@
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
import type { AppStateDto } from './dto/AppStateDto'
import type { CatalogListingDto } from './dto/CatalogListingDto'
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
@@ -30,6 +31,12 @@ export interface BridgeApi {
removeGame: (name: string) => Promise<void>
launchGame: (name: string) => Promise<boolean>
readAccount: () => Promise<AccountDto>
/** Answers with the code to show; how it ended arrives on `onSignInFinished`. */
beginSignIn: () => Promise<SignInPromptDto>
cancelSignIn: () => Promise<void>
signOut: () => Promise<AccountDto>
listRegistryStores: () => Promise<RegistryResultDto>
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
selectStore: (home: string) => Promise<StoreSelectionDto>
@@ -40,6 +47,7 @@ export interface BridgeApi {
onLog: (listener: StreamListener<string>) => void
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
onBusyChanged: (listener: StreamListener<boolean>) => void
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
}
/** The name the bridge is published under on `window`. */
+8 -1
View File
@@ -19,6 +19,11 @@ export const IPC_CHANNELS = {
catalogRemoveGame: 'catalog:removeGame',
catalogLaunchGame: 'catalog:launchGame',
accountRead: 'account:read',
accountBeginSignIn: 'account:beginSignIn',
accountCancelSignIn: 'account:cancelSignIn',
accountSignOut: 'account:signOut',
storeListRegistry: 'store:listRegistry',
storeInstallStore: 'store:installStore',
storeSelectStore: 'store:selectStore',
@@ -26,7 +31,9 @@ export const IPC_CHANNELS = {
/** Main to renderer, one way. */
streamLog: 'stream:log',
streamSyncEvent: 'stream:syncEvent',
streamBusyChanged: 'stream:busyChanged'
streamBusyChanged: 'stream:busyChanged',
/** How a sign-in ended, once the browser half is done. */
streamSignInFinished: 'stream:signInFinished'
} as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
+21
View File
@@ -0,0 +1,21 @@
/** Where this machine stands with one store. */
export interface AccountDto {
readonly signInAvailable: boolean
readonly signedIn: boolean
}
/** What to show while somebody finishes signing in in their browser. */
export interface SignInPromptDto {
/** The short code, read off this screen and typed into a browser. */
readonly userCode: string
readonly verificationUrl: string
readonly expiresInSeconds: number
}
export type SignInOutcomeDto = 'signedIn' | 'denied' | 'expired' | 'cancelled'
/** The end of a sign-in, pushed to the window when the waiting is over. */
export interface SignInFinishedDto {
readonly outcome: SignInOutcomeDto
readonly account: AccountDto
}
@@ -1,3 +1,4 @@
import type { AccountDto } from './AccountDto'
import type { GameDto } from './GameDto'
import type { StorePathsDto } from './StorePathsDto'
@@ -6,4 +7,12 @@ export interface CatalogListingDto {
readonly games: readonly GameDto[]
readonly skipped: readonly string[]
readonly paths: StorePathsDto | null
/**
* Where this machine stood with the store when the catalog was read.
*
* Carried with the listing rather than asked for separately, because the entitlement
* each entry reports is only meaningful next to whether anybody was signed in when
* the catalog answered.
*/
readonly account: AccountDto
}
+18
View File
@@ -1,6 +1,19 @@
/** How a title runs: unpacked on this machine, or served as a web build. */
export type GameModeDto = 'app' | 'web'
/**
* Whether this person can install this title.
*
* Separate from `installable`, which is about the machine. A title can have a perfectly
* good build for this architecture and still not be yours.
*
* - `open` nothing to own; install it
* - `entitled` owned; install it
* - `purchasable` not owned, and here is the price
* - `signInRequired` the catalog would say, if it knew who was asking
*/
export type AccessVerdictDto = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
/** Why a title cannot be installed on this machine. */
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
@@ -30,4 +43,9 @@ export interface GameDto {
readonly installable: boolean
readonly unavailableReason: UnavailableReasonDto | null
readonly unavailableDetail: string | null
readonly accessVerdict: AccessVerdictDto
/** Already formatted for the window's locale; null where there is no price to show. */
readonly priceLabel: string | null
/** Opened in the person's own browser. Null where the catalog named none. */
readonly purchaseUrl: string | null
}
+23 -1
View File
@@ -31,6 +31,8 @@ export const ENGLISH_MESSAGES = {
catInstalled: 'Installed',
catUpdates: 'Updates',
catAvailable: 'Not installed',
catOwned: 'Owned',
catPurchasable: 'To buy',
catUnsupported: 'Not for this machine',
catPlatform: 'Platform',
catMode: 'Kind',
@@ -52,7 +54,27 @@ export const ENGLISH_MESSAGES = {
failed: 'failed',
removed: 'removed',
upToDate: 'Everything is up to date.',
of: 'of'
of: 'of',
owned: 'owned',
ownedHint: 'This account owns it — install it on this machine',
purchase: 'Buy',
purchaseHint: 'Opens the store page in your browser',
signInToInstall: 'Sign in to install',
signInToInstallHint: 'This title needs an account; the catalog will say whether you own it',
account: 'Account',
signIn: 'Sign in…',
signOut: 'Sign out',
signedIn: 'Signed in',
signInTitle: 'Sign in to %{store}',
signInBody: 'Your browser is opening the sign-in page. Type this code there:',
signInOpenAgain: 'Open the page again',
signInWaiting: 'Waiting for you to approve it…',
signInCancel: 'Cancel',
signInDone: 'Signed in.',
signInDenied: 'That sign-in was refused.',
signInExpired: 'That code expired. Try again.',
signInCancelled: 'Sign-in cancelled.',
signInFailed: 'Signing in did not work.'
} as const
/** Every string the window can show, by key. */
+23 -1
View File
@@ -31,6 +31,8 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
catInstalled: 'Telepítve',
catUpdates: 'Frissítés',
catAvailable: 'Nincs telepítve',
catOwned: 'Birtokolt',
catPurchasable: 'Megvehető',
catUnsupported: 'Erre a gépre nem',
catPlatform: 'Platform',
catMode: 'Fajta',
@@ -52,5 +54,25 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
failed: 'hiba',
removed: 'eltávolítva',
upToDate: 'Minden naprakész.',
of: '/'
of: '/',
owned: 'birtokolt',
ownedHint: 'Ez a fiók birtokolja — telepítheted erre a gépre',
purchase: 'Megvásárlás',
purchaseHint: 'Megnyitja a bolt oldalát a böngésződben',
signInToInstall: 'Belépés a telepítéshez',
signInToInstallHint: 'Ehhez a címhez fiók kell; a katalógus akkor mondja meg, birtoklod-e',
account: 'Fiók',
signIn: 'Belépés…',
signOut: 'Kilépés',
signedIn: 'Belépve',
signInTitle: 'Belépés ide: %{store}',
signInBody: 'Megnyílik a böngésződ a belépő oldallal. Írd be ott ezt a kódot:',
signInOpenAgain: 'Oldal újranyitása',
signInWaiting: 'Várunk a jóváhagyásra…',
signInCancel: 'Mégsem',
signInDone: 'Beléptél.',
signInDenied: 'A belépést elutasították.',
signInExpired: 'A kód lejárt. Próbáld újra.',
signInCancelled: 'Belépés megszakítva.',
signInFailed: 'A belépés nem sikerült.'
}