31da86980051ca6b0c515ae1c5ea8d7deb24c33d
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
31da869800 |
The app has an icon
Every build so far shipped the default Electron one. electron-builder said so on every run — "default Electron icon is used, reason=application icon is not set" — in a line that is very easy to read past. A store people install games with should not look like a framework demo in the Dock. The mark is a W with three lines running into it: the product's initial, and what it is doing. It uses the window's own palette, so the icon and the application it opens are the same object. Drawn for the smallest size first, which is what settled it — at 32px the W still reads and the lines survive as motion rather than as noise, where a ring, an outline or fine detail did not. A portal, a play triangle and a send arrow were each drawn and each discarded: they already mean a loading spinner, a media player and a submit button. `resources/icon.svg` is the source and the only file anybody should edit. `make icons` renders the rest. Three committed binaries with no way to regenerate them is how an icon becomes something nobody dares change, so the ICO is written here rather than shelling out to ImageMagick — the container is a header and 16 bytes per image, which is not worth a build dependency this machine does not have. `directories.buildResources` had to move off the default: electron-builder looks in `build/`, which this project uses for compiled output and wipes on `make clean`, so the icons would have been deleted before every package. The window picks it up when run from source too, where there is otherwise nothing to carry an icon and a dev run looks like a different application. Guarded on `app.isPackaged`, because `resources/` is not inside the package and pointing at it there would be a path that does not exist. Verified by reading the icon back out of the built bundle rather than trusting the config: extracted from `WarpEngine Client.app/Contents/Resources/icon.icns` and looked at, and the ICO parsed entry by entry — 7 images, 16 to 256, each a valid PNG. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6285d93790 |
Stores can be removed, and added from an address you type
Two gaps that were the same gap: the store list could only ever grow, and it could only grow from what the registry happened to offer. **Removing** uninstalls what the store installed, then deletes the store itself, in that order. The order is the whole of it: `state.json` is the only record of which payloads, icons and menu entries belong to a store, so deleting the home first would strip the one thing that knows — leaving files nothing could ever identify, least of all a later install of the same store into the same folder. The confirmation says how many titles will go, because that is the part nobody would otherwise expect. The token goes too; a credential for a store that is not here is a secret kept for nothing. The window names a *store*, never a path: the home is resolved against what a disk scan actually found before anything is deleted, and `removeHome` refuses anything else. That is the only guard between a bad argument and `rm -rf`, so it has a test. **Adding** moved to a + beside Refresh — both are actions on the whole store rather than on one of them, and the full-width button under the list read as a third store — and the picker now takes a catalog address as well as a listed one. A bare host is enough and the name comes from the address; nothing else about installing changes, which is why the typed path hands the same record to the same method instead of growing a second one. The picker also has a Cancel now: opening it with a store installed used to replace the grid with no way back. `make storetest` is new, and it earned itself immediately. Removal is the only code here that deletes a directory tree, which the smoke test cannot cover — it runs against the real machine and would have to delete a real store to prove anything. Two bugs on the first run: - `http://` was accepted and became a store called *http*. The trailing slashes were stripped before the scheme was checked, turning `http://` into `http:` and then into `https://http:`, whose hostname parses as "http". The URL is rebuilt from the parsed form now, which also settles the trailing slash in one place. - `STORE_ROOT` only *prepended* to the search path, so a "sandboxed" run still listed the real stores — despite the README saying "instead of the real one". Harmless while a sandbox could only add; not harmless now that it can delete. It replaces the search path. The self-test needed two changes, both of which are it working: the store row is a wrapper now, so clicking `.store-row` did nothing at all, and the footer icon check counted exactly two named controls when there are three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
255c588cbd |
The self-test was a stopwatch, not a check
ci/woodpecker/push/woodpecker Pipeline was successful
`--selftest` slept six seconds and then photographed whatever was on screen. On the first cold run of the freshly packaged 2.4.0 — Gatekeeper checking the bundle, a first DNS lookup, the catalog still in flight — six seconds was not enough, and it reported an empty window as SELFTEST FAILED. Four runs immediately afterwards passed with all 13 cards. The window was fine; the guess about how long somebody else's machine takes was not. It now polls for a settled window — a card, or the gate — and gives up only at a 30-second ceiling. The common case got *faster* than the old fixed wait (the run finishes in about three seconds rather than always at least six), and the cold case passes. A timeout is deliberately not a failure by itself: the report is taken anyway and the existing checks decide, so a genuinely empty window still fails for the right reason instead of as a bare timeout. This is a diagnostic, not the product: nothing here runs without `--selftest`, which is why 2.4.0 shipped as it is rather than being retagged over it. I made the flake marginally likelier by adding one more startup request — the service descriptor — so fixing it is mine to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d42355189 |
The smoke test can be somebody
Without a token the signed-in half of a gated catalog is untestable here: the real credential store is the OS keychain reached through Electron, and there is no Electron in this process, so every title comes back `signInRequired` and "owned" and "not owned" never happen. SMOKE_TOKEN supplies one. Against a live Orbit it now reports `open:1, purchasable:1, entitled:1` — the free title, the one this account has not bought, and the one it has — which is the first end-to-end proof that the access block survives the whole path from the engine's policy to a card. It only ever reads. A smoke run must not leave a credential on the machine that ran it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26c7aa9be1 |
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> |
||
|
|
e35a72336a |
Upgrade from the card, behind a three-dot menu
An installed title's version line now reads `0.1 → 0.3` where the catalog has moved on, so a card answers both questions somebody brings to it: what is installed, and is there anything better. Which version is installed was already recorded — `state.json` has always carried it — what was missing was anywhere to act on it. The card leads with Play (or Open, for a hosted title) and puts the rest behind a ⋮ button: Upgrade, which fetches whatever the catalog now has, and Uninstall. Upgrade stays visible while disabled rather than appearing and disappearing — a menu whose items come and go makes a person hunt for the one they used last time, and greyed out already says "not now". Playing stays the headline even with an upgrade waiting: the build on the disk still runs, and wanting to play it is not the same as wanting to wait for a download. The menu is a `<details>`, so its open state is the DOM's and the keyboard needs no teaching. Closing it on an outside click is the grid's job, not a card's: cards are rebuilt on every render, so a listener per card would be a listener per render. Package names lose their spaces — `WarpEngineClient-2.3.0-arm64.dmg` — because a space in a release asset is a space in every curl, script and shell command that touches it. Set per target rather than globally: nsis and portable would otherwise resolve to the same .exe name and overwrite each other. `productName` is untouched, so the app is still called WarpEngine Client where a person sees it — in the Dock and in /Applications. Tested on a sandbox store by rewriting one state record to claim an older build, which is what the engine actually compares: the window then offered `Upgrade:on` for that title and `Upgrade:off` for the current one, and pressing it took the record from 0.1 to 0.2 with the old payload removed first. The self-test asserts that pairing on every installed card, because a closed menu photographs identically whether or not its items are right. In Hungarian the catalog refresh and the new Upgrade both wanted "Frissítés"; the refresh is an icon with a tooltip, and a tooltip can afford to say *Katalógus frissítése*. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
82590d3ec4 |
A registry record is a name and a catalog
`config` — added this morning in 2.1.0 — is gone, and `storeRepositoryUrl` with it, along with the two store repositories they pointed at. 2.1.0 had the registry say how each store behaves. Wrong shape: how a store behaves is fixed per installed client, and this application is the only thing that can see the machine it runs on. A copy of that on a server was a second authority over decisions this side had already made correctly — including which directories the store may delete from — and two authorities are a way to disagree. Keeping two stores on one machine apart needs none of it. It is a subfolder, derived here: the store id is a slug of the catalog host, the home is `<id>-desktop`, the games folder is `<id>`, and that folder is the only subtree the store will ever delete from. Derived from the *catalog* on purpose — the catalog is what a store is, so two records naming the same one are the same store and land in the same place, which makes installing twice idempotent instead of a way to orphan what is already there. Existing installations keep their identity: a store home is recognised by its own `config.json`, so one installed as `ttg` stays `ttg` in `ttg-desktop` with its games where they are. Only a new install derives its id. `StoreProvisioningService` no longer re-reads the registry before installing. That existed to keep the renderer from supplying a config, and with no config in the record there is nothing left to protect: a name and a catalog have no paths in them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
045c7bf5b7 |
Read a store's config from the registry record
`GET /api/stores` records now carry a `config` field — a store's `config.json` moved into the record that already said what the store is — and the client applies it directly. Installing a store no longer depends on a second repository existing and staying reachable, and a store can be configured from the site's admin alone. The order is registry config, then a repository's `config.json`, then the engine's defaults. The middle one is why nothing has to move at once: a registry whose stores have not been migrated is read exactly as before. The window cannot supply a config. It is handed stores to show and hands one back to install, but only as an identity: `RegistryStoreDtoMapper.toModel` drops the config and `StoreProvisioningService` reads the record again from the registry first. A config decides where files are written and, through `paths.subfolder`, which subtree the store may later delete from — not a decision the renderer gets to make, for the same reason a `GameDto` carries no paths. Tested by installing from a record carrying `subfolder: "ATTACKER"` and `install_root: "/tmp/pwned"` and finding neither on disk. A store that has left the registry, or a registry that cannot be re-read, still installs: it falls back to the engine's defaults rather than refusing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
06f3f2a3b1 |
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>
|
||
|
|
8511ccbef8 |
WarpEngine Client: the whole catalog, and a build that can point elsewhere
**The app is called WarpEngine Client.** "Store" named the thing it opens rather than the
thing you run, and the store is a catalog on a site, not a window on your machine. The
window title, the bundle, the packages and the menu entry follow; the repository already
did. The store being driven is named in the side menu, so the bar stopped repeating it as
a badge — the element stays in the page, hidden, because the window check reads it.
**Every title is listed, including the ones this machine cannot install.** They arrive
from the engine with `installable: false` and a reason, and they are drawn dimmed, with an
*unsupported platform* or *no build for this machine* badge, the engine's own sentence
underneath, and nothing to press: a disabled Install would invite a click that can never
work. They get a category of their own — *Not for this machine* — and they are kept out of
the native/hosted categories and counts, because a title with no build has no mode to be
counted under. An engine older than desktop 1.2.0 is unaffected: a missing `installable`
field reads as installable, which is what those engines mean.
**A build can be pointed at another site's registry:**
make dist STORES_API=https://games.example.org/api/stores
BuildConfiguration reads the packaged package.json, where electron-builder's
extraMetadata writes that address, so a client for somebody else's catalog needs no source
change and nothing set on the user's machine. Precedence is runtime environment, then
build, then ours — three audiences, most specific first.
Also: the scrollbars are the window's own, because the platform's light track down the
side menu of a dark window looked like a mistake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
526c67b069 |
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> |
||
|
|
3d63c8a0b0 |
TypeScript, in layers, with a strict linter
The client was one main.js, one preload.js, three files in lib/ and one renderer
script. It is now a typed application whose imports point inward: domain (models,
ports, errors) knows nothing about Electron, Node or Python; application orchestrates
it through those ports; infrastructure holds the adapters — the Python CLI, HTTP, the
filesystem, Electron itself — and main, preload and renderer sit on top as hosts.
STRUCTURE.md is the map, and the deliverable as much as the code is: every layer, every
pattern in use (ports and adapters, repository vs gateway, service, DTO and mapper,
composition root, controller and router, single flight, observer streams, state store
with unidirectional flow, passive view, coded error hierarchy, frozen constant tables,
untrusted-data readers) and the naming rules — files, classes, and a verb vocabulary
for methods where find/require/read/list/apply/render/handle each state a contract.
Two properties fell out of the move, and they are why it was worth doing:
- The catalog can be driven with no window and no Electron at all. The smoke test
assembles the same services against the same ports in a plain Node process; it used
to be a script that reimplemented the bridge.
- The window never receives a filesystem path. A title crosses the bridge without
one, and launching is asked for by name, resolved in the main process from the
store's own state. Verified with a fake launcher: an unknown name answers false, a
native title resolves to its menu entry, a hosted one to its catalog URL.
Types are mandatory, including where inference would manage: explicit return,
parameter and property types, strict plus noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride and noPropertyAccessFromIndexSignature,
typescript-eslint strictTypeChecked and stylisticTypeChecked, exhaustive switches, no
any, no non-null assertions, and no casts on foreign data — engine stdout and the
registry go through readers that turn unknown into typed values. naming-convention
enforces the patterns rather than trusting them.
Two rule conflicts had to be decided rather than papered over. typedef and
no-inferrable-types disagree about `fallback: string = ''`: the annotation wins, since a
signature states its types. erasableSyntaxOnly is off, because it forbids constructor
parameter properties, which are how dependencies are declared here.
The preload and the renderer are bundled by esbuild into one file each: a sandboxed
preload may not require its own modules, and a module script over file:// is blocked by
the page's own origin rules. tsc compiles the rest. The package ships build/** and
package.json — 111 entries, no sources, no toolchain.
New targets: build, typecheck, lint, lint-fix, and check — typecheck, lint, then both
test suites, cheapest failure first. Every script that runs the app builds first, so a
stale bundle cannot be tested.
Nothing about the window changed: same side menu, same categories, same switcher, same
two languages. make check is clean, both test suites pass with one store and with two,
the packaged 1.3.0 bundle drives the real store, and the window was photographed before
and after — the two are the same picture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|