Registry-only stores, a quieter window, and a CI that builds
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:
2026-08-18 16:52:59 +02:00
co-authored by Claude Opus 5
parent 3d63c8a0b0
commit 526c67b069
29 changed files with 581 additions and 184 deletions
+12 -5
View File
@@ -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()
}
}
}
+12 -14
View File
@@ -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) {
+5 -5
View File
@@ -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>
+10 -1
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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 -23
View File
@@ -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)
+25 -6
View File
@@ -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) {
-5
View File
@@ -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
+2
View File
@@ -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)