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
+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)) {