The store engine moves into the client, and Python goes with it
Reading the catalog, choosing the release that fits this machine, unpacking it,
writing the menu entry and remembering what went where all happen in process now.
There is no interpreter to find, no child process, and no JSON-lines protocol
between the two halves — `PythonEngineProcessRunner`, the runtime locator, the two
engine mappers and the version negotiation are all gone, and with them the one
unchecked cast this codebase had (engine stdout to a typed event).
What that buys a person: on Windows and on a fresh Mac the app simply works. It
used to look for `python3`, `python` and `py -3` and draw a link to python.org
where none answered.
What lands on disk is unchanged, deliberately. `config.json` and `state.json` keep
the shell engine's snake_case shape, its `<scope>:<name>` keys and its file modes,
so a machine whose library was installed by the CLI keeps it — verified against the
Python engine on the same catalog: the same 13-title listing with zero field
differences, byte-identical payloads, identical modes and an identical Info.plist,
and a re-sync over a Python-installed home that writes nothing. Remove, prune,
prune-suppression on a named sync and the v1 state migration were each exercised.
Three things worth knowing about the new code:
- the zip reader is ~150 lines over `node:zlib`, because Node has none and this
application has no runtime dependencies. It restores the executable bit from
each entry's external attributes, without which nothing installed can start,
and it refuses zip64, unknown compression and paths that escape the
destination rather than guessing;
- `SUPPORTED_WARP_ENGINE_VERSIONS` names the engine versions this client is
written against, checked against the `WarpEngine-Version` header every
response carries. `selectCatalogDialect` switches over that list exhaustively,
so adding a version fails the build — type checker and linter both — until
somebody says what its catalog reads like. An absent header is read as the
oldest version, which is what an engine before 0.4.0 is;
- refresh and the language picker are icons at the foot of the side menu now,
both named for a tooltip and a screen reader, the picker still a real
`<select>` under its glyph.
The repository is free of Python as well: the Makefile, the CI check and the
release script read package.json and the forge's JSON with Node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,18 +70,10 @@ export class RendererApplication {
|
||||
this.store.applyAppState(await this.bridge.readState())
|
||||
const state = this.store.readState()
|
||||
|
||||
if (state.pythonVersion === null) {
|
||||
this.stores.showMissingPythonGate()
|
||||
return
|
||||
}
|
||||
if (state.currentStore === null) {
|
||||
await this.stores.offerStores()
|
||||
return
|
||||
}
|
||||
if (state.engine !== null && !state.engine.supported) {
|
||||
this.stores.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
}
|
||||
|
||||
@@ -24,11 +24,7 @@ export class StoreController {
|
||||
const messages = this.store.readState().messages
|
||||
try {
|
||||
const selection = await this.bridge.selectStore(home)
|
||||
this.store.applySelectedStore(selection.store, selection.engine)
|
||||
if (selection.engine !== null && !selection.engine.supported) {
|
||||
this.showOutdatedEngineGate()
|
||||
return
|
||||
}
|
||||
this.store.applySelectedStore(selection.store)
|
||||
this.store.applyGate(null)
|
||||
await this.catalog.refresh()
|
||||
} catch (error: unknown) {
|
||||
@@ -75,25 +71,6 @@ export class StoreController {
|
||||
})
|
||||
}
|
||||
|
||||
public showOutdatedEngineGate (): void {
|
||||
const state = this.store.readState()
|
||||
const engineText = state.engine === null ? '' : state.engine.text
|
||||
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() } }
|
||||
})
|
||||
}
|
||||
|
||||
public showMissingPythonGate (): void {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyGate({
|
||||
title: messages.noPythonTitle,
|
||||
body: messages.noPythonBody,
|
||||
link: { label: messages.pythonLink, url: 'https://www.python.org/downloads/' }
|
||||
})
|
||||
}
|
||||
|
||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
|
||||
+27
-9
@@ -36,11 +36,6 @@
|
||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||
</section>
|
||||
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-actions"></h2>
|
||||
<button id="refresh" class="btn btn-wide" disabled></button>
|
||||
</section>
|
||||
|
||||
<section class="side-block side-cats">
|
||||
<h2 class="side-head" id="head-cats"></h2>
|
||||
<nav class="cats" id="cats"></nav>
|
||||
@@ -48,10 +43,33 @@
|
||||
|
||||
<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>
|
||||
</label>
|
||||
<!--
|
||||
Two icons and nothing else. Both carry their name in `title` and `aria-label`,
|
||||
set from the message bundle, so the tooltip and the screen reader stay
|
||||
translated while the button stays the size of its glyph.
|
||||
-->
|
||||
<div class="side-tools">
|
||||
<button id="refresh" class="icon-btn" disabled>
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M13.5 8a5.5 5.5 0 1 1-1.61-3.89" />
|
||||
<path d="M13.5 2v3h-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<!--
|
||||
The select is still a real `<select>`, stretched over the icon and invisible:
|
||||
the native dropdown knows how to open upward in a cramped window and is
|
||||
already keyboard- and screen-reader-navigable, which a hand-rolled menu would
|
||||
have to earn back.
|
||||
-->
|
||||
<label class="icon-btn side-lang" id="locale-control">
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M2 8h12" />
|
||||
<path d="M8 2c1.8 1.6 2.8 3.8 2.8 6S9.8 12.4 8 14C6.2 12.4 5.2 10.2 5.2 8S6.2 3.6 8 2Z" />
|
||||
</svg>
|
||||
<select id="locale" class="locale-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AppStateDto } from '../../shared/contracts/dto/AppStateDto'
|
||||
import type { EngineVersionDto } from '../../shared/contracts/dto/EngineVersionDto'
|
||||
import type { GameDto } from '../../shared/contracts/dto/GameDto'
|
||||
import type { InstalledStoreDto } from '../../shared/contracts/dto/InstalledStoreDto'
|
||||
import type { StorePathsDto } from '../../shared/contracts/dto/StorePathsDto'
|
||||
@@ -21,11 +20,8 @@ export interface AppState {
|
||||
readonly locales: readonly Locale[]
|
||||
readonly messages: MessageBundle
|
||||
readonly navigationOpen: boolean
|
||||
readonly pythonVersion: string | null
|
||||
readonly stores: readonly InstalledStoreDto[]
|
||||
readonly currentStore: InstalledStoreDto | null
|
||||
readonly engine: EngineVersionDto | null
|
||||
readonly minimumEngineVersion: string
|
||||
readonly registryUrl: string
|
||||
readonly defaultStoreRoot: string
|
||||
readonly games: readonly GameDto[]
|
||||
@@ -42,11 +38,8 @@ const INITIAL_STATE: AppState = {
|
||||
locales: ['en'],
|
||||
messages: ENGLISH_MESSAGES,
|
||||
navigationOpen: true,
|
||||
pythonVersion: null,
|
||||
stores: [],
|
||||
currentStore: null,
|
||||
engine: null,
|
||||
minimumEngineVersion: '',
|
||||
registryUrl: '',
|
||||
defaultStoreRoot: '',
|
||||
games: [],
|
||||
@@ -85,11 +78,8 @@ export class AppStore {
|
||||
locales: dto.locales,
|
||||
messages: dto.messages,
|
||||
navigationOpen: dto.navigationOpen,
|
||||
pythonVersion: dto.pythonVersion,
|
||||
stores: dto.stores,
|
||||
currentStore: dto.currentStore,
|
||||
engine: dto.engine,
|
||||
minimumEngineVersion: dto.minimumEngineVersion,
|
||||
registryUrl: dto.registryUrl,
|
||||
defaultStoreRoot: dto.defaultStoreRoot
|
||||
}
|
||||
@@ -111,8 +101,8 @@ export class AppStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
public applySelectedStore (store: InstalledStoreDto, engine: EngineVersionDto | null): void {
|
||||
this.state = { ...this.state, currentStore: store, engine, games: [], paths: null, filter: ALL_CATEGORIES }
|
||||
public applySelectedStore (store: InstalledStoreDto): void {
|
||||
this.state = { ...this.state, currentStore: store, games: [], paths: null, filter: ALL_CATEGORIES }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
|
||||
+50
-3
@@ -85,7 +85,11 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
/* The icons sit at the end of the footer row, away from the log toggle. */
|
||||
.side-tools { display: flex; align-items: center; gap: 4px; margin-left: auto; }
|
||||
.side-head {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -167,8 +171,51 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
padding: 8px 10px 2px;
|
||||
}
|
||||
|
||||
.side-lang { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-dim); }
|
||||
.side-lang .select { margin-left: auto; }
|
||||
/*
|
||||
* An icon-only control: a square the size of its glyph, quiet until pointed at.
|
||||
* `position: relative` is what lets the language picker put its <select> on top.
|
||||
*/
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||
.icon-btn:active:not(:disabled) { transform: scale(.94); }
|
||||
.icon-btn:disabled { opacity: .4; cursor: default; }
|
||||
/* The select swallows focus, so the ring has to be drawn on the label around it. */
|
||||
.icon-btn:focus-visible,
|
||||
.icon-btn:focus-within { color: var(--ink); border-color: var(--accent); outline: none; }
|
||||
|
||||
.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5;
|
||||
stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
/*
|
||||
* The real <select>, stretched over the icon and invisible. It keeps the native
|
||||
* dropdown — and its keyboard and screen-reader behaviour — while only the glyph shows.
|
||||
*/
|
||||
.locale-select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* --- top bar ------------------------------------------------------------ */
|
||||
.bar {
|
||||
@@ -223,7 +270,7 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||
|
||||
/* --- gate (no python, or no store yet) ---------------------------------- */
|
||||
/* --- gate (no store yet) ---------------------------------- */
|
||||
.gate {
|
||||
margin: auto;
|
||||
max-width: 520px;
|
||||
|
||||
@@ -21,14 +21,14 @@ export interface SideMenuViewCallbacks {
|
||||
*/
|
||||
export class SideMenuView {
|
||||
private readonly storesHead = requireElement('head-stores', HTMLElement)
|
||||
private readonly actionsHead = requireElement('head-actions', HTMLElement)
|
||||
private readonly categoriesHead = requireElement('head-cats', HTMLElement)
|
||||
private readonly languageHead = requireElement('head-lang', HTMLElement)
|
||||
private readonly storeList = requireElement('store-list', HTMLElement)
|
||||
private readonly addStore = requireElement('add-store', HTMLButtonElement)
|
||||
private readonly refresh = requireElement('refresh', HTMLButtonElement)
|
||||
private readonly categories = requireElement('cats', HTMLElement)
|
||||
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)
|
||||
|
||||
public constructor (private readonly callbacks: SideMenuViewCallbacks) {
|
||||
this.addStore.addEventListener('click', callbacks.onAddStore)
|
||||
@@ -38,11 +38,15 @@ export class SideMenuView {
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.storesHead, state.messages.stores)
|
||||
setText(this.actionsHead, state.messages.actions)
|
||||
setText(this.categoriesHead, state.messages.categories)
|
||||
setText(this.languageHead, state.messages.language)
|
||||
setText(this.addStore, state.messages.addStore)
|
||||
setText(this.refresh, state.messages.refresh)
|
||||
// Refresh and the language picker are icons: naming them is all the view does, and
|
||||
// writing text into them would replace the glyph.
|
||||
describeControl(this.refresh, state.messages.refresh)
|
||||
// The picker is two elements: the invisible select takes the focus and the reader's
|
||||
// name, the square around it takes the hover and the tooltip.
|
||||
describeControl(this.locale, state.messages.language)
|
||||
this.localeControl.title = state.messages.language
|
||||
|
||||
this.renderStores(state)
|
||||
this.renderCategories(state)
|
||||
@@ -117,3 +121,14 @@ export class SideMenuView {
|
||||
for (const row of this.storeList.querySelectorAll('button')) row.disabled = state.busy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name an icon-only control.
|
||||
*
|
||||
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads;
|
||||
* an icon button needs both, and they are the same sentence.
|
||||
*/
|
||||
function describeControl (element: HTMLElement, name: string): void {
|
||||
element.title = name
|
||||
element.setAttribute('aria-label', name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user