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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user