The store engine moves into the client, and Python goes with it
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

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:
2026-08-18 23:36:25 +02:00
co-authored by Claude Opus 5
parent 8511ccbef8
commit 06f3f2a3b1
66 changed files with 3327 additions and 762 deletions
+6 -10
View File
@@ -7,14 +7,12 @@ import { StoreProvisioningService } from '../../application/services/StoreProvis
import { StoreSelectionService } from '../../application/services/StoreSelectionService'
import { ElectronApplicationEnvironment } from '../../infrastructure/electron/ElectronApplicationEnvironment'
import { ElectronGameLauncher } from '../../infrastructure/electron/ElectronGameLauncher'
import { NativeStoreCatalogGateway } from '../../infrastructure/engine/NativeStoreCatalogGateway'
import { HttpTextClient } from '../../infrastructure/http/HttpTextClient'
import { PythonEngineProcessRunner } from '../../infrastructure/process/PythonEngineProcessRunner'
import { SystemPythonRuntimeLocator } from '../../infrastructure/process/SystemPythonRuntimeLocator'
import { FileSystemInstalledStoreRepository } from '../../infrastructure/repositories/FileSystemInstalledStoreRepository'
import { HttpStoreEngineInstaller } from '../../infrastructure/repositories/HttpStoreEngineInstaller'
import { NativeStoreEngineInstaller } from '../../infrastructure/repositories/NativeStoreEngineInstaller'
import { HttpStoreRegistryRepository } from '../../infrastructure/repositories/HttpStoreRegistryRepository'
import { JsonFilePreferencesRepository } from '../../infrastructure/repositories/JsonFilePreferencesRepository'
import { PythonStoreCatalogGateway } from '../../infrastructure/repositories/PythonStoreCatalogGateway'
import { AppIpcController } from '../ipc/AppIpcController'
import { CatalogIpcController } from '../ipc/CatalogIpcController'
import { IpcRouter } from '../ipc/IpcRouter'
@@ -46,22 +44,20 @@ export class ServiceContainer {
const environment = new ElectronApplicationEnvironment(app)
const httpClient = new HttpTextClient()
const pythonLocator = new SystemPythonRuntimeLocator()
const engineRunner = new PythonEngineProcessRunner(pythonLocator)
const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new PythonStoreCatalogGateway(engineRunner)
const catalogGateway = new NativeStoreCatalogGateway()
const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new HttpStoreEngineInstaller(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient)
const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment)
this.selection = new StoreSelectionService(stores, catalogGateway, preferences)
this.selection = new StoreSelectionService(stores, preferences)
this.catalog = new CatalogService(catalogGateway, this.selection)
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
this.state = new ApplicationStateService(
preferences, this.selection, this.provisioning, pythonLocator, environment
preferences, this.selection, this.provisioning, environment
)
this.controllers = [
+18 -3
View File
@@ -26,6 +26,8 @@ interface SelfTestReport {
readonly paths: string
readonly logLines: number
readonly locales: readonly string[]
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
readonly iconControls: readonly string[]
}
/** What changed after clicking a store that was not open. */
@@ -68,7 +70,11 @@ export class SelfTestRunner {
// 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 && (
// Both footer icons must be present, named and drawn: refresh and the language
// picker are the only way to reach those two actions now that neither has a label.
const iconsNamed = report.iconControls.length === 2 &&
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
const rendered = report.locales.length > 1 && iconsNamed && (
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateAction.length > 0))
@@ -98,7 +104,15 @@ export class SelfTestRunner {
activeCategory: (document.querySelector('#cats .cat.is-active') || {}).textContent || null,
paths: document.getElementById('log-paths').textContent.slice(0, 120),
logLines: document.querySelectorAll('.log-line').length,
locales: [...document.getElementById('locale').options].map((option) => option.value)
locales: [...document.getElementById('locale').options].map((option) => option.value),
// The two icon-only controls: a glyph with no accessible name is a button nobody
// can identify, and the failure is silent because the icon still draws.
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
.map((control) => {
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
(control.querySelector('[aria-label]') || {}).ariaLabel || ''
return named + ':' + control.querySelectorAll('svg.icon').length
})
})`))) ?? {}
return {
@@ -117,7 +131,8 @@ export class SelfTestRunner {
activeCategory: readOptionalString(record, 'activeCategory'),
paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales')
locales: readStringArray(record, 'locales'),
iconControls: readStringArray(record, 'iconControls')
}
}
+2 -9
View File
@@ -1,5 +1,4 @@
import { InstalledStoreDtoMapper } from '../../application/mappers/InstalledStoreDtoMapper'
import { EngineVersionDtoMapper } from '../../application/mappers/EngineVersionDtoMapper'
import { RegistryStoreDtoMapper } from '../../application/mappers/RegistryStoreDtoMapper'
import type { StoreProvisioningService } from '../../application/services/StoreProvisioningService'
import type { StoreSelectionService } from '../../application/services/StoreSelectionService'
@@ -20,8 +19,7 @@ export class StoreIpcController {
private readonly guard: SingleFlightGuard,
private readonly streams: WindowStreamBroadcaster,
private readonly registryMapper: RegistryStoreDtoMapper = new RegistryStoreDtoMapper(),
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper(),
private readonly engineMapper: EngineVersionDtoMapper = new EngineVersionDtoMapper()
private readonly storeMapper: InstalledStoreDtoMapper = new InstalledStoreDtoMapper()
) {}
public register (router: IpcRouter): void {
@@ -63,11 +61,6 @@ export class StoreIpcController {
}
private handleSelectStore (home: string): StoreSelectionDto {
const store = this.selection.selectStore(home)
const engine = this.selection.findEngineVersion(store)
return {
store: this.storeMapper.toDto(store),
engine: engine === null ? null : this.engineMapper.toDto(engine)
}
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
}
}