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
+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
}
}