Registry-only stores, a quieter window, and a CI that builds
ci/woodpecker/manual/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
A store no longer needs a repository of its own. The engine's built-in defaults already cover the host-to-asset mapping, the install modes, the platforms and the behaviour; what they cannot know is identity — a slug, a name and a catalog URL — and that is exactly what a registry record carries. So `storeRepositoryUrl` is optional: a record with a name and a catalog is a complete store, the id falls back from the repository name to the catalog host (`teletypegames.org` becomes `teletypegames`) to the display name, and the client writes a three-section config. Given a repository it still reads it, and that file stays the authority on how the store behaves; a repository without a config.json is treated as no repository at all. Measured end to end against a local registry serving one record with a null repository: the engine and the core downloaded, engine 1.1.0 accepted the written config, it listed the same ten titles the configured store does, and a hosted title synced into a sandbox with its menu entry written. The "Install all" button is gone, and with it the string it used. Titles are installed one at a time from their own cards. No footer. The window carried a bar at the bottom at all times — a toggle and a line of absolute paths — for something most sessions never need. The log is still there, folder buttons included, behind a quiet switch at the bottom of the side menu; it takes no room until it is opened, and an arriving line does not open it, because the store logs on every refresh and a window that unfolds panels by itself is worse than one that keeps quiet. Three faults that every automated count had passed, found by photographing the setup screen: the store badge rendered as an empty pill with no store open; the gate's picker showed as an empty dropdown stub, because an explicit `display` beats the browser's own `[hidden]` rule; and the gate went up while the empty-catalog line stayed on screen underneath it. The last was a design fault — whether the gate is up was a call on a view rather than state, so the two could disagree. The setup screen is now a field in the state store, and that one field decides which of the gate and the grid is drawn. The window test's gate assertion was wrong too: it demanded a store picker, which only appears when the registry offers more than one store, so one store — the ordinary case — failed it. CI builds the packages this machine cannot. `.woodpecker.yaml` runs the checks on every push and, on a tag or by hand, builds the Linux packages in `electronuserland/builder:22` and the Windows ones in `:22-wine`, then attaches them to the release with scripts/ci-upload.sh. The pipeline lives here rather than in the update server's `/build/config` extension, which serves game-platform pipelines publishing into the site's catalog — a different product with a different target. macOS stays a local build: Apple's toolchain and its signing exist only on a Mac. Both build steps verify what they produced, because a half-finished Wine build leaves a 162 KB stub named like the real installer and `ls` is happy with it. The Linux step was rehearsed locally in the same image (AppImage 128 MB, deb 100 MB); the Wine step cannot be rehearsed on Apple Silicon, where 16 KB host pages break Wine's 4 KB assumption, so the runner is where it is proven. The size check was tested against both outcomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* A store the site's registry offers.
|
||||
*
|
||||
* Three fields, because that is what a record is: what it is called, which
|
||||
* catalog it serves, and where its configuration lives.
|
||||
* A name and a catalog are what make a store; the repository is optional. When
|
||||
* there is one it stays the authority on how that store behaves — which platforms
|
||||
* it offers, where things land — and when there is not, the engine's own defaults
|
||||
* cover all of it and this record covers the identity. That is the whole reason a
|
||||
* store needs no repository of its own.
|
||||
*/
|
||||
export interface RegistryStore {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
readonly storeRepositoryUrl: string | null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import type { RegistryStore } from './RegistryStore'
|
||||
|
||||
/**
|
||||
* A store id from its repository name: `ttg-desktop-store` becomes `ttg`.
|
||||
* A store id, from whatever the registry gave us.
|
||||
*
|
||||
* The id names the store home and the folder games land in, so it has to be short
|
||||
* and filesystem-safe. The repository name is the best source available before
|
||||
* anything is downloaded; the store's own config.json overrides it once it is.
|
||||
* The id names the store home, the folder games land in and the launcher files, so
|
||||
* it has to be short and filesystem-safe. Three sources, in order of how much they
|
||||
* were meant to be a name:
|
||||
*
|
||||
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
|
||||
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
|
||||
* 3. the display name, slugged, as a last resort.
|
||||
*
|
||||
* The store's own config.json overrides all of it whenever one exists.
|
||||
*/
|
||||
export function deriveStoreId (store: RegistryStore): string {
|
||||
const lastSegment = store.storeRepositoryUrl.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
const base = lastSegment.replace(/-(desktop-)?store$/, '') || store.name
|
||||
const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return slug || 'store'
|
||||
const fromRepository = store.storeRepositoryUrl === null
|
||||
? ''
|
||||
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
|
||||
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
|
||||
}
|
||||
|
||||
function lastSegment (url: string): string {
|
||||
return url.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
}
|
||||
|
||||
/** `https://www.teletypegames.org/x` → `teletypegames`. */
|
||||
function readHostLabel (catalogUrl: string): string {
|
||||
try {
|
||||
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||
return host.split('.')[0] ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug (value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import https from 'node:https'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60_000
|
||||
const MAX_REDIRECTS = 5
|
||||
const USER_AGENT = 'warp-engine-desktop-gui'
|
||||
const USER_AGENT = 'warp-engine-client'
|
||||
|
||||
/** A response that arrived but said no. The status matters: 404 is not a failure everywhere. */
|
||||
export class HttpStatusError extends Error {
|
||||
|
||||
@@ -76,30 +76,26 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||
/**
|
||||
* The store's configuration.
|
||||
*
|
||||
* Its repository is the authority on how the store behaves — which platforms,
|
||||
* which statuses, where things land. A repository without a config.json still
|
||||
* works: the engine merges whatever it is given onto its own defaults, so a
|
||||
* three-field config is a complete one. The registry wins on identity and on
|
||||
* which catalog to read.
|
||||
* Three cases, and all of them install:
|
||||
*
|
||||
* - **a repository with a config.json** — that file is the authority on how the
|
||||
* store behaves: which platforms it offers, which statuses it shows, where
|
||||
* things land;
|
||||
* - **a repository without one** (404) — the engine's defaults, as below;
|
||||
* - **no repository at all** — the same defaults, without the round trip.
|
||||
*
|
||||
* The engine's built-in defaults already cover the host-to-asset mapping, the
|
||||
* modes, the platforms and the behaviour, so what a store actually has to supply
|
||||
* is identity: a slug, a name and a catalog. That is exactly what a registry
|
||||
* record carries, which is why a store needs no repository of its own. The
|
||||
* registry always wins on those three, whatever a config file says.
|
||||
*/
|
||||
private async readStoreConfig (
|
||||
store: RegistryStore,
|
||||
progress: EngineProgressListener
|
||||
): Promise<Record<string, unknown>> {
|
||||
const storeId = deriveStoreId(store)
|
||||
let config: Record<string, unknown>
|
||||
try {
|
||||
progress.onLog?.(`reading the store config from ${store.storeRepositoryUrl}`)
|
||||
const body = await this.httpClient.readText(this.configUrl(store.storeRepositoryUrl))
|
||||
config = { ...(asRecord(JSON.parse(body)) ?? {}) }
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
|
||||
progress.onLog?.('no config.json in the store repository — using the engine defaults')
|
||||
config = {
|
||||
paths: { subfolder: storeId },
|
||||
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||
}
|
||||
}
|
||||
const config = await this.readPublishedConfig(store, storeId, progress)
|
||||
|
||||
const existing = asRecord(config['store']) ?? {}
|
||||
config['store'] = {
|
||||
@@ -111,6 +107,43 @@ export class HttpStoreEngineInstaller implements StoreEngineInstaller {
|
||||
return config
|
||||
}
|
||||
|
||||
private async readPublishedConfig (
|
||||
store: RegistryStore,
|
||||
storeId: string,
|
||||
progress: EngineProgressListener
|
||||
): Promise<Record<string, unknown>> {
|
||||
const repositoryUrl = store.storeRepositoryUrl
|
||||
if (repositoryUrl === null) {
|
||||
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
|
||||
return this.defaultConfig(storeId)
|
||||
}
|
||||
try {
|
||||
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
|
||||
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
|
||||
return { ...(asRecord(JSON.parse(body)) ?? {}) }
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
|
||||
progress.onLog?.('no config.json in the store repository — using the engine defaults')
|
||||
return this.defaultConfig(storeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a store gets when nothing else says otherwise.
|
||||
*
|
||||
* Two fields, on top of the identity added by the caller. The subfolder keeps two
|
||||
* stores on one machine out of each other's files, and it is the prune boundary,
|
||||
* so it must be the store's own. Demo titles are listed because a catalog that
|
||||
* publishes them means them to be played — the engine defaults to released and
|
||||
* archived only, which is the safer default for a store nobody configured.
|
||||
*/
|
||||
private defaultConfig (storeId: string): Record<string, unknown> {
|
||||
return {
|
||||
paths: { subfolder: storeId },
|
||||
catalog: { statuses: ['released', 'archived', 'demo'] }
|
||||
}
|
||||
}
|
||||
|
||||
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
|
||||
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
|
||||
* The registry: `GET /api/stores` on the site.
|
||||
*
|
||||
* The one address this client knows, and even that is overridable — `STORES_API`
|
||||
* points it at another site or at a local endpoint. Records missing any of the
|
||||
* three fields are dropped rather than half-used.
|
||||
* points it at another site or at a local endpoint.
|
||||
*
|
||||
* A record needs a name and a catalog URL; those two make a store. The repository
|
||||
* is optional and arrives as null when absent — a store configured by nothing but
|
||||
* this record installs on the engine's defaults. Records missing either of the two
|
||||
* required fields are dropped rather than half-used.
|
||||
*/
|
||||
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
public readonly sourceUrl: string
|
||||
@@ -32,16 +36,19 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
|
||||
return parsed
|
||||
.map((row: unknown): JsonRecord | null => asRecord(row))
|
||||
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
|
||||
.map((row: JsonRecord): RegistryStore => ({
|
||||
name: readString(row, 'name').trim(),
|
||||
.map((row: JsonRecord): RegistryStore => {
|
||||
// Both spellings, because a registry is someone else's API: ours answers
|
||||
// camelCase, and a hand-rolled one may not.
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: (
|
||||
const repository = (
|
||||
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
|
||||
).trim()
|
||||
}))
|
||||
return {
|
||||
name: readString(row, 'name').trim(),
|
||||
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
|
||||
storeRepositoryUrl: repository.length > 0 ? repository : null
|
||||
}
|
||||
})
|
||||
.filter((store: RegistryStore): boolean =>
|
||||
store.name.length > 0 && store.catalogUrl.length > 0 && store.storeRepositoryUrl.length > 0)
|
||||
store.name.length > 0 && store.catalogUrl.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +65,13 @@ export class SelfTestRunner {
|
||||
|
||||
if (this.shotPath !== null) await this.captureShot(this.shotPath)
|
||||
|
||||
// A gate passes on having something to do, not on having a picker: the picker
|
||||
// only appears when the registry offers more than one store, and one store is
|
||||
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||
const rendered = report.locales.length > 1 && (
|
||||
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||
(report.gateVisible && report.gateChoices.length > 0 && report.gateAction.length > 0))
|
||||
(report.gateVisible && report.gateAction.length > 0))
|
||||
const switchedWell = switched === null || (
|
||||
switched.storeId.length > 0 && switched.storeId !== report.storeId &&
|
||||
switched.cards > 0 && switched.categories > 0)
|
||||
|
||||
@@ -27,14 +27,16 @@ export function requireStringArray (value: unknown, name: string): readonly stri
|
||||
export function requireRegistryStore (value: unknown): RegistryStoreDto {
|
||||
const record = asRecord(value)
|
||||
if (record === null) throw new TypeError('a store record is required')
|
||||
const repository = readString(record, 'storeRepositoryUrl')
|
||||
const store: RegistryStoreDto = {
|
||||
name: readString(record, 'name'),
|
||||
catalogUrl: readString(record, 'catalogUrl'),
|
||||
storeRepositoryUrl: readString(record, 'storeRepositoryUrl'),
|
||||
// Optional: a store with no repository installs on the engine's defaults.
|
||||
storeRepositoryUrl: repository.length > 0 ? repository : null,
|
||||
storeId: readString(record, 'storeId')
|
||||
}
|
||||
if (store.name.length === 0 || store.catalogUrl.length === 0 || store.storeRepositoryUrl.length === 0) {
|
||||
throw new TypeError('a store record needs a name, a catalog URL and a repository URL')
|
||||
if (store.name.length === 0 || store.catalogUrl.length === 0) {
|
||||
throw new TypeError('a store record needs a name and a catalog URL')
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class RendererApplication {
|
||||
})
|
||||
this.gate = new GateView((url: string): void => { void this.bridge.openUrl(url) })
|
||||
this.catalog = new CatalogController(this.bridge, this.store, this.log)
|
||||
this.stores = new StoreController(this.bridge, this.store, this.gate, this.log, this.catalog)
|
||||
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)
|
||||
|
||||
@@ -56,7 +56,6 @@ export class RendererApplication {
|
||||
this.sideMenu = new SideMenuView({
|
||||
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||
onAddStore: (): void => { void this.stores.offerStores() },
|
||||
onSyncAll: (): void => { void this.catalog.syncGames([]) },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
onSelectLocale: (locale: string): void => { void this.preferences.selectLocale(locale) }
|
||||
@@ -83,7 +82,7 @@ export class RendererApplication {
|
||||
this.stores.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
}
|
||||
|
||||
@@ -100,7 +99,15 @@ export class RendererApplication {
|
||||
this.topBar.render(state)
|
||||
this.sideMenu.render(state)
|
||||
this.log.render(state)
|
||||
if (this.gate.visible) this.grid.hide()
|
||||
else this.grid.render(state)
|
||||
|
||||
// The gate and the grid are alternatives, decided by one field, so they cannot
|
||||
// both be on screen — which is what happened while this was two imperative calls.
|
||||
if (state.gate === null) {
|
||||
this.gate.hide()
|
||||
this.grid.render(state)
|
||||
} else {
|
||||
this.gate.show(state.gate, state.messages)
|
||||
this.grid.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { BridgeApi } from '../../shared/contracts/BridgeApi'
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import type { AppStore } from '../state/AppStore'
|
||||
import type { GateView } from '../views/GateView'
|
||||
import type { LogDrawerView } from '../views/LogDrawerView'
|
||||
import type { CatalogController } from './CatalogController'
|
||||
|
||||
@@ -16,7 +15,6 @@ export class StoreController {
|
||||
public constructor (
|
||||
private readonly bridge: BridgeApi,
|
||||
private readonly store: AppStore,
|
||||
private readonly gate: GateView,
|
||||
private readonly log: LogDrawerView,
|
||||
private readonly catalog: CatalogController
|
||||
) {}
|
||||
@@ -31,7 +29,7 @@ export class StoreController {
|
||||
this.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
} catch (error: unknown) {
|
||||
this.log.appendLine(`${messages.switchFailed}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
@@ -48,23 +46,23 @@ export class StoreController {
|
||||
const result = await this.bridge.listRegistryStores()
|
||||
|
||||
if (result.error !== null) {
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.registryFailed,
|
||||
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
||||
}, messages)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.stores.length === 0) {
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
}, messages)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.setupBody}\n\n${state.defaultStoreRoot}`,
|
||||
action: {
|
||||
@@ -74,26 +72,26 @@ export class StoreController {
|
||||
}
|
||||
},
|
||||
choices: result.stores
|
||||
}, messages)
|
||||
})
|
||||
}
|
||||
|
||||
public showOutdatedEngineGate (): void {
|
||||
const state = this.store.readState()
|
||||
const engineText = state.engine === null ? '' : state.engine.text
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: state.messages.oldEngineTitle,
|
||||
body: `${state.messages.oldEngineBody}\n\n${engineText} → ${state.minimumEngineVersion}`,
|
||||
action: { label: state.messages.oldEngineAction, perform: (): void => { void this.offerStores() } }
|
||||
}, state.messages)
|
||||
})
|
||||
}
|
||||
|
||||
public showMissingPythonGate (): void {
|
||||
const messages = this.store.readState().messages
|
||||
this.gate.show({
|
||||
this.store.applyGate({
|
||||
title: messages.noPythonTitle,
|
||||
body: messages.noPythonBody,
|
||||
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
||||
}, messages)
|
||||
})
|
||||
}
|
||||
|
||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||
@@ -102,7 +100,7 @@ export class StoreController {
|
||||
try {
|
||||
await this.bridge.installStore(chosen)
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
this.gate.hide()
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
await this.catalog.syncGames([])
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-actions"></h2>
|
||||
<button id="sync-all" class="btn btn-primary btn-wide" disabled></button>
|
||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
||||
</section>
|
||||
|
||||
@@ -46,6 +45,7 @@
|
||||
</section>
|
||||
|
||||
<section class="side-block side-foot">
|
||||
<button id="log-toggle" class="side-quiet" aria-expanded="false" aria-controls="log-panel"></button>
|
||||
<label class="side-lang">
|
||||
<span id="head-lang"></span>
|
||||
<select id="locale" class="select" aria-label="Language"></select>
|
||||
@@ -72,11 +72,11 @@
|
||||
|
||||
<section id="empty" class="empty" hidden></section>
|
||||
|
||||
<footer class="log">
|
||||
<button id="log-toggle" class="log-toggle" aria-expanded="false"></button>
|
||||
<div class="log-lines" id="log-lines" hidden></div>
|
||||
<!-- Only on screen while the log is open; the window has no permanent footer. -->
|
||||
<section class="log-panel" id="log-panel" hidden>
|
||||
<div class="log-lines" id="log-lines"></div>
|
||||
<div class="log-paths" id="log-paths"></div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||
import { ENGLISH_MESSAGES } from '../../shared/i18n/EnglishMessages'
|
||||
import type { Locale, MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
import { ALL_CATEGORIES, type CategoryFilter } from './CategoryFilter'
|
||||
import type { GatePresentation } from './GatePresentation'
|
||||
|
||||
/** How far a running sync has got, for the counter in the bar. */
|
||||
export interface SyncProgress {
|
||||
@@ -32,6 +33,8 @@ export interface AppState {
|
||||
readonly filter: CategoryFilter
|
||||
readonly busy: boolean
|
||||
readonly progress: SyncProgress | null
|
||||
/** Non-null while the setup screen is up, which is also what hides the grid. */
|
||||
readonly gate: GatePresentation | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AppState = {
|
||||
@@ -50,7 +53,8 @@ const INITIAL_STATE: AppState = {
|
||||
paths: null,
|
||||
filter: ALL_CATEGORIES,
|
||||
busy: false,
|
||||
progress: null
|
||||
progress: null,
|
||||
gate: null
|
||||
}
|
||||
|
||||
export type AppStateListener = (state: AppState) => void
|
||||
@@ -112,6 +116,11 @@ export class AppStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyGate (gate: GatePresentation | null): void {
|
||||
this.state = { ...this.state, gate }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applyFilter (filter: CategoryFilter): void {
|
||||
this.state = { ...this.state, filter }
|
||||
this.notify()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
|
||||
/** A button on the gate, and what choosing it does. */
|
||||
export interface GateAction {
|
||||
readonly label: string
|
||||
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||
}
|
||||
|
||||
export interface GateLink {
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gate is showing.
|
||||
*
|
||||
* Part of the state rather than a call on a view: whether the gate is up decides
|
||||
* whether the grid is drawn, and the two disagreed when this was imperative — the
|
||||
* gate went up and the empty-catalog line stayed underneath it.
|
||||
*/
|
||||
export interface GatePresentation {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
}
|
||||
+21
-6
@@ -14,6 +14,11 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
||||
regions here have one — the gate's store picker showed as an empty stub because
|
||||
of exactly that. This makes `hidden` mean hidden everywhere. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
@@ -60,7 +65,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
|
||||
.side-block { display: flex; flex-direction: column; gap: 6px; flex: none; }
|
||||
.side-cats { flex: 1; min-height: 0; }
|
||||
.side-foot { padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.side-foot {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
gap: 8px;
|
||||
}
|
||||
.side-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -279,23 +288,29 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- log ---------------------------------------------------------------- */
|
||||
.log {
|
||||
/* --- log ----------------------------------------------------------------
|
||||
No permanent footer: the panel is in the flow only while it is open, and the
|
||||
switch for it sits in the side menu with everything else that is not a title. */
|
||||
.log-panel {
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 18px 10px;
|
||||
}
|
||||
.log-toggle {
|
||||
.side-quiet {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 0 4px;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
margin-left: -6px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.side-quiet:hover { color: var(--ink); background: var(--panel-2); }
|
||||
.side-quiet.is-active { color: var(--accent); }
|
||||
.log-lines {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||
|
||||
/** A button on the gate, and what choosing it does. */
|
||||
export interface GateAction {
|
||||
readonly label: string
|
||||
readonly perform: (chosen: RegistryStoreDto | null) => void
|
||||
}
|
||||
|
||||
export interface GateLink {
|
||||
readonly label: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
export interface GatePresentation {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
readonly choices?: readonly RegistryStoreDto[]
|
||||
}
|
||||
import type { GateLink, GatePresentation } from '../state/GatePresentation'
|
||||
|
||||
/**
|
||||
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||
@@ -50,10 +32,6 @@ export class GateView {
|
||||
setHidden(this.section, true)
|
||||
}
|
||||
|
||||
public get visible (): boolean {
|
||||
return !this.section.hidden
|
||||
}
|
||||
|
||||
/** Only shown when the registry offers more than one store; with a single one there is nothing to decide. */
|
||||
private renderChoices (choices: readonly RegistryStoreDto[], messages: MessageBundle): void {
|
||||
setHidden(this.choice, choices.length < 2)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement, requireElement, setText } from '../dom/Dom'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
import type { AppState } from '../state/AppStore'
|
||||
|
||||
const MAX_LOG_LINES = 400
|
||||
@@ -7,17 +7,23 @@ export interface LogDrawerViewCallbacks {
|
||||
readonly onOpenFolder: (directory: string) => void
|
||||
}
|
||||
|
||||
/** The store's own output, verbatim, and the folders everything lands in. */
|
||||
/**
|
||||
* The store's own output, verbatim, and the folders everything lands in.
|
||||
*
|
||||
* Off screen until asked for. The window used to carry a footer with a toggle and a
|
||||
* line of absolute paths at all times; both were clutter next to the one thing the
|
||||
* window is for, which is the titles. The switch lives in the side menu with the rest
|
||||
* of what is not a title, and the panel appears above the grid only while it is on.
|
||||
*/
|
||||
export class LogDrawerView {
|
||||
private readonly toggle = requireElement('log-toggle', HTMLButtonElement)
|
||||
private readonly panel = requireElement('log-panel', HTMLElement)
|
||||
private readonly lines = requireElement('log-lines', HTMLElement)
|
||||
private readonly pathsBox = requireElement('log-paths', HTMLElement)
|
||||
private open = false
|
||||
|
||||
public constructor (private readonly callbacks: LogDrawerViewCallbacks) {
|
||||
this.toggle.addEventListener('click', (): void => {
|
||||
this.lines.hidden = !this.lines.hidden
|
||||
this.toggle.setAttribute('aria-expanded', String(!this.lines.hidden))
|
||||
})
|
||||
this.toggle.addEventListener('click', (): void => { this.setOpen(!this.open) })
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
@@ -40,6 +46,19 @@ export class LogDrawerView {
|
||||
}
|
||||
}
|
||||
|
||||
private setOpen (open: boolean): void {
|
||||
this.open = open
|
||||
setHidden(this.panel, !open)
|
||||
this.toggle.setAttribute('aria-expanded', String(open))
|
||||
this.toggle.classList.toggle('is-active', open)
|
||||
if (open) this.lines.scrollTop = this.lines.scrollHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* A line arriving while the panel is shut does not open it: the store logs on every
|
||||
* refresh, and a window that unfolded a panel by itself would be worse than one that
|
||||
* kept quiet. The lines are kept, so opening it later shows what happened.
|
||||
*/
|
||||
public appendLine (line: string): void {
|
||||
this.lines.appendChild(createElement('div', 'log-line', line))
|
||||
while (this.lines.childElementCount > MAX_LOG_LINES) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { AppState } from '../state/AppStore'
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onSyncAll: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||
readonly onSelectLocale: (locale: string) => void
|
||||
@@ -27,14 +26,12 @@ export class SideMenuView {
|
||||
private readonly languageHead = requireElement('head-lang', HTMLElement)
|
||||
private readonly storeList = requireElement('store-list', HTMLElement)
|
||||
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
||||
private readonly syncAll = requireElement('sync-all', HTMLButtonElement)
|
||||
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
||||
private readonly categories = requireElement('cats', HTMLElement)
|
||||
private readonly locale = requireElement('locale', HTMLSelectElement)
|
||||
|
||||
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||
this.syncAll.addEventListener('click', callbacks.onSyncAll)
|
||||
this.refresh.addEventListener('click', callbacks.onRefresh)
|
||||
this.locale.addEventListener('change', (): void => { callbacks.onSelectLocale(this.locale.value) })
|
||||
}
|
||||
@@ -45,7 +42,6 @@ export class SideMenuView {
|
||||
setText(this.categoriesHead, state.messages.categories)
|
||||
setText(this.languageHead, state.messages.language)
|
||||
setText(this.addStore, state.messages.addStore)
|
||||
setText(this.syncAll, state.messages.syncAll)
|
||||
setText(this.refresh, state.messages.refresh)
|
||||
|
||||
this.renderStores(state)
|
||||
@@ -116,7 +112,6 @@ export class SideMenuView {
|
||||
*/
|
||||
private renderEnabled (state: AppState): void {
|
||||
const hasStore = state.currentStore !== null
|
||||
this.syncAll.disabled = state.busy || !hasStore
|
||||
this.refresh.disabled = state.busy || !hasStore
|
||||
this.addStore.disabled = state.busy
|
||||
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
||||
|
||||
@@ -18,6 +18,8 @@ export class TopBarView {
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.appName, state.messages.appName)
|
||||
// The badge is a bordered pill: empty, it renders as a stub next to the title.
|
||||
setHidden(this.storeId, state.currentStore === null)
|
||||
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
||||
this.navToggle.title = state.messages.menu
|
||||
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
||||
|
||||
@@ -40,7 +40,7 @@ class SmokeTest {
|
||||
private readonly gameMapper = new GameDtoMapper()
|
||||
|
||||
public async run (): Promise<number> {
|
||||
console.log('warp-engine-desktop-gui smoke test')
|
||||
console.log('warp-engine-client smoke test')
|
||||
|
||||
if (!this.checkPython()) return 1
|
||||
this.checkMessages()
|
||||
@@ -89,10 +89,14 @@ class SmokeTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* A store repository without a config.json still installs — the engine merges what
|
||||
* it is given onto its defaults — so an absent file is reported, not failed.
|
||||
* A store needs no repository, and a repository needs no config.json: either way
|
||||
* the engine's defaults carry it. So both absences are reported, not failed.
|
||||
*/
|
||||
private async checkStoreConfig (store: RegistryStore): Promise<void> {
|
||||
if (store.storeRepositoryUrl === null) {
|
||||
this.reportOk(' config', 'no repository — the engine defaults would be used')
|
||||
return
|
||||
}
|
||||
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
|
||||
try {
|
||||
const config: unknown = JSON.parse(await this.httpClient.readText(url))
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
export interface RegistryStoreDto {
|
||||
readonly name: string
|
||||
readonly catalogUrl: string
|
||||
readonly storeRepositoryUrl: string
|
||||
/** Derived from the repository name, so the picker can show what it will become. */
|
||||
/** Null when the store has no repository of its own; the engine's defaults are then used. */
|
||||
readonly storeRepositoryUrl: string | null
|
||||
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
|
||||
readonly storeId: string
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Install all',
|
||||
refresh: 'Refresh',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { MessageBundle } from './MessageBundle'
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Store',
|
||||
syncAll: 'Mind telepítése',
|
||||
refresh: 'Frissítés',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
|
||||
Reference in New Issue
Block a user