Stores can be removed, and added from an address you type
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

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>
This commit is contained in:
2026-08-19 14:54:17 +02:00
co-authored by Claude Opus 5
parent 255c588cbd
commit 6285d93790
27 changed files with 823 additions and 72 deletions
+24 -6
View File
@@ -159,11 +159,16 @@ Everything that is not a title lives in the **side menu** on the left, and the
another switches to it: the grid, the categories and the folders all follow, and
the client reopens on that store next time. Two stores installed from the same
catalog into different folders show their folder instead of their id, because
the id would not tell them apart. **Add a store…** brings up the registry
picker, the same one the first run offers.
the id would not tell them apart. Hovering a row shows a **bin**, which takes that
store off the machine — see below.
- **+**, beside Refresh, brings up the picker: the stores the registry offers, and a
field for **any catalog address of your own**. A bare host is enough (`https` is
assumed) and the name is taken from it. This is the same screen the first run shows,
so a machine with no store yet can also start from a typed address rather than only
from the list.
- **Account** appears only where the catalog offers a sign-in, and holds *Sign in…* or
*Sign out* — see above.
- **Actions** holds **Refresh**, which re-reads the catalog. Titles are installed
- **Actions** holds **Refresh**, which re-reads the catalog, and **+** to add one. Titles are installed
one at a time from their own cards; there is no install-everything button.
- **Categories** narrows the grid, one category at a time, with the count next to
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
@@ -202,6 +207,13 @@ While the store is working, only the things that would start a second call are
disabled: the menu, the log drawer and the category filters keep working, because
they change what is on screen and nothing on disk.
**Removing a store uninstalls what it installed.** The bin on a store row asks first,
and says how many titles will go with it. That is not a convenience — a store's
`state.json` is the only record of which payloads, icons and menu entries belong to it,
so leaving the games behind would leave orphans nothing could ever identify, least of
all a later install of the same store into the same folder. The catalog cache, the
settings and any sign-in token go too.
Anything installed from the window is a normal menu entry, so it also shows up in
your launcher, Dock or Start menu — the app does not have to be running to play.
@@ -216,9 +228,10 @@ names. `make` on its own lists everything.
| `make build` | compile TypeScript, bundle the preload and the renderer |
| `make typecheck` | type-check everything, emitting nothing |
| `make lint` | the strict rule set (`lint-fix` fixes what it can) |
| `make check` | **typecheck, lint and both test suites** — the gate |
| `make check` | **typecheck, lint and every test suite** — the gate |
| `make start` | run the app against whatever store is installed |
| `make smoke` | drive the store with no window and no Electron at all |
| `make storetest` | add and remove a store in a sandbox — the only code that deletes a tree |
| `SMOKE_HOME=<dir> SMOKE_TOKEN=<bearer> npm run smoke` | the same, against a sandbox store and as a signed-in person |
| `make uitest` | load the window once and report what rendered |
| `SELFTEST_SHOT=shot.png npm run uitest` | the same, and the window photographs itself into that file |
@@ -340,14 +353,19 @@ own runtime.
single-instance lock. Otherwise a copy the user already has open swallows the test
process, which exits 0 and reads as a pass.
Both test scripts accept a sandbox store instead of the real one, which is how
this repository is tested without touching a working installation:
The test scripts accept a sandbox store instead of the real one, which is how this
repository is tested without touching a working installation:
```sh
STORE_ROOT=/tmp/sandbox-root npm start
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
```
**`STORE_ROOT` replaces the search path rather than being added to the front of it.**
It used to prepend, so a "sandboxed" run still listed the real stores and could switch
to one; now that a store can also be *removed*, a sandbox that can reach a working
installation is not a sandbox. `make storetest` relies on this.
### How it is put together
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)