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>
This commit is contained in:
@@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest test \
|
.PHONY: help setup node-check build typecheck lint lint-fix check start smoke uitest storetest test \
|
||||||
dist dist-mac dist-win dist-linux release publish clean distclean version
|
dist dist-mac dist-win dist-linux release publish clean distclean version
|
||||||
|
|
||||||
help: ## List available targets
|
help: ## List available targets
|
||||||
@@ -72,7 +72,7 @@ lint-fix: ## Lint and fix what can be fixed automatically
|
|||||||
|
|
||||||
# The order is deliberate: a type error explains a lint error, and both explain a
|
# The order is deliberate: a type error explains a lint error, and both explain a
|
||||||
# failing test, so the cheapest check that can fail runs first.
|
# failing test, so the cheapest check that can fail runs first.
|
||||||
check: typecheck lint test ## Type-check, lint, and run both test suites
|
check: typecheck lint test ## Type-check, lint, and run every test suite
|
||||||
|
|
||||||
start: ## Run the app against whatever store is installed
|
start: ## Run the app against whatever store is installed
|
||||||
npm start
|
npm start
|
||||||
@@ -83,7 +83,10 @@ smoke: ## Drive the store bridge with no window at all
|
|||||||
uitest: ## Load the window once and report what rendered
|
uitest: ## Load the window once and report what rendered
|
||||||
npm run uitest
|
npm run uitest
|
||||||
|
|
||||||
test: smoke uitest ## Both checks
|
storetest: ## Add and remove a store in a sandbox (the only code that deletes a tree)
|
||||||
|
npm run storetest
|
||||||
|
|
||||||
|
test: smoke storetest uitest ## All three checks
|
||||||
|
|
||||||
dist: node-check ## Package for this machine
|
dist: node-check ## Package for this machine
|
||||||
npm run dist $(BUILDER_ARGS)
|
npm run dist $(BUILDER_ARGS)
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
the id would not tell them apart. Hovering a row shows a **bin**, which takes that
|
||||||
picker, the same one the first run offers.
|
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
|
- **Account** appears only where the catalog offers a sign-in, and holds *Sign in…* or
|
||||||
*Sign out* — see above.
|
*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.
|
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
|
- **Categories** narrows the grid, one category at a time, with the count next to
|
||||||
each: *Everything*, *Installed*, *Updates*, *Not installed*, then a row per
|
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
|
disabled: the menu, the log drawer and the category filters keep working, because
|
||||||
they change what is on screen and nothing on disk.
|
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
|
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.
|
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 build` | compile TypeScript, bundle the preload and the renderer |
|
||||||
| `make typecheck` | type-check everything, emitting nothing |
|
| `make typecheck` | type-check everything, emitting nothing |
|
||||||
| `make lint` | the strict rule set (`lint-fix` fixes what it can) |
|
| `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 start` | run the app against whatever store is installed |
|
||||||
| `make smoke` | drive the store with no window and no Electron at all |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
single-instance lock. Otherwise a copy the user already has open swallows the test
|
||||||
process, which exits 0 and reads as a pass.
|
process, which exits 0 and reads as a pass.
|
||||||
|
|
||||||
Both test scripts accept a sandbox store instead of the real one, which is how
|
The test scripts accept a sandbox store instead of the real one, which is how this
|
||||||
this repository is tested without touching a working installation:
|
repository is tested without touching a working installation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
STORE_ROOT=/tmp/sandbox-root npm start
|
STORE_ROOT=/tmp/sandbox-root npm start
|
||||||
SMOKE_HOME=/tmp/sandbox-root/ttg-desktop npm run smoke
|
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
|
### How it is put together
|
||||||
|
|
||||||
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
|
TypeScript, in layers, with the dependency rule pointing inward. **[STRUCTURE.md](STRUCTURE.md)
|
||||||
|
|||||||
+25
-29
@@ -1,34 +1,30 @@
|
|||||||
# WarpEngine Client 2.4.0
|
# WarpEngine Client 2.5.0
|
||||||
|
|
||||||
**The catalog can now say a title is not yours, and the client can do something about
|
**A store can be taken off the machine again.** Hovering a store in the side menu shows
|
||||||
it.** Where a store sells things, a card shows the **price** and a **Buy** button that
|
a bin. It asks first, and says how many titles will go with it — because removing a
|
||||||
opens the store's own page; where you own it, an **Install** as before. Two new
|
store *uninstalls what it installed*. That is not a convenience: a store's `state.json`
|
||||||
categories go with it — **Owned** and **To buy** — because owning something is not the
|
is the only record of which payloads, icons and menu entries belong to it, so leaving
|
||||||
same as having installed it.
|
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.
|
||||||
|
|
||||||
**Signing in, without a password ever reaching this window.** The side menu grows an
|
**Adding one moved to a + beside Refresh**, and it now takes a catalog address of your
|
||||||
**Account** block, and signing in shows a short code: your browser opens on the store's
|
own as well as the ones the registry lists. A bare host is enough — `https` is assumed —
|
||||||
own page and you type it there. That detour is the point — a desktop application asking
|
and the name is taken from the address. Both are actions on the whole store rather than
|
||||||
for a password is a desktop application people should not be giving one to. The token
|
on one of them, which is why they sit together; the full-width "Add a store…" button
|
||||||
lives in the OS keychain (Keychain, libsecret, DPAPI), one per store, and *Sign out*
|
under the list read as a third store.
|
||||||
revokes it at the server as well as forgetting it here.
|
|
||||||
|
|
||||||
**None of this is knowledge about any particular store.** It all arrives from the
|
**The picker has a way out.** Opening it with a store already installed used to be a
|
||||||
catalog's own server: WarpEngine 0.5 answers `GET /api/service` with what it offers, and
|
trap: the grid was replaced and nothing short of installing something brought it back.
|
||||||
puts an `access` block on every entry. A client that carried those facts would work for
|
|
||||||
exactly one shop — this one asks, which is why the same build serves any of them.
|
|
||||||
|
|
||||||
**A store with no sign-in shows none.** Every WarpEngine before 0.5 has no descriptor at
|
**Two things found by the new test.** `make storetest` adds and removes a store in a
|
||||||
all, and a 0.5 store that sells nothing reports none either. Both read as "a plain
|
sandbox, because removal is the only code here that deletes a directory tree and the
|
||||||
catalog", which is what this client assumed for its whole life until now, and the window
|
path it deletes is named by the window. It immediately caught that `http://` was
|
||||||
behaves accordingly: no Account block, no prices, no new categories.
|
accepted and became a store called *http* — the trailing slashes were being stripped
|
||||||
|
before the scheme was checked — and that `STORE_ROOT` only *prepended* to the search
|
||||||
|
path, so a "sandboxed" run still listed the real stores. With a delete button in the
|
||||||
|
window, a sandbox that can reach a working installation is not a sandbox; it replaces
|
||||||
|
the search path now.
|
||||||
|
|
||||||
**A title nobody has bought is not dimmed.** The dimming and the dashed badge belong to
|
**`btn-secondary` had no styling at all.** It was introduced in 2.4.0 on the card's
|
||||||
what this *machine* cannot do — an unsupported platform, no build for this architecture
|
sign-in button and on the sign-in panel, and rendered as a plain button in both places.
|
||||||
— and there is nothing wrong with the machine when a title simply costs money.
|
|
||||||
|
|
||||||
**A bearer token is never sent to a host that did not issue it.** A gated download
|
|
||||||
answers with a redirect to signed storage, often somebody else's server, and some object
|
|
||||||
stores refuse a request outright when an `Authorization` header rides along with the
|
|
||||||
signature. The credential stops at the origin it belongs to; a redirect back to the
|
|
||||||
catalog keeps it.
|
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "warp-engine-client",
|
"name": "warp-engine-client",
|
||||||
"productName": "WarpEngine Client",
|
"productName": "WarpEngine Client",
|
||||||
"version": "2.4.0",
|
"version": "2.5.0",
|
||||||
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Teletype Games <games@teletype.hu>",
|
"author": "Teletype Games <games@teletype.hu>",
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:mac": "npm run build && electron-builder --mac",
|
"dist:mac": "npm run build && electron-builder --mac",
|
||||||
"dist:win": "npm run build && electron-builder --win",
|
"dist:win": "npm run build && electron-builder --win",
|
||||||
"dist:linux": "npm run build && electron-builder --linux"
|
"dist:linux": "npm run build && electron-builder --linux",
|
||||||
|
"storetest": "npm run build && node build/scripts/StoreLifecycleTest.js",
|
||||||
|
"test": "npm run smoke && npm run storetest && npm run uitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ export class PreferencesService {
|
|||||||
this.merge({ storeHome: home })
|
this.merge({ storeHome: home })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop remembering a store, for when it is no longer on the machine.
|
||||||
|
*
|
||||||
|
* The key is removed rather than blanked: an empty string would be a remembered home
|
||||||
|
* that matches nothing, and every reader would have to know to treat it as absent.
|
||||||
|
*/
|
||||||
|
public forgetStoreHome (): void {
|
||||||
|
const { storeHome, ...rest } = this.repository.read()
|
||||||
|
void storeHome
|
||||||
|
this.repository.write(rest)
|
||||||
|
}
|
||||||
|
|
||||||
private merge (changes: Preferences): void {
|
private merge (changes: Preferences): void {
|
||||||
this.repository.write({ ...this.repository.read(), ...changes })
|
this.repository.write({ ...this.repository.read(), ...changes })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
|
|||||||
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
import { deriveStoreId } from '../../domain/models/StoreIdentity'
|
||||||
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||||
|
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||||
import type { StoreSelectionService } from './StoreSelectionService'
|
import type { StoreSelectionService } from './StoreSelectionService'
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ export class StoreProvisioningService {
|
|||||||
private readonly registry: StoreRegistryRepository,
|
private readonly registry: StoreRegistryRepository,
|
||||||
private readonly installer: StoreEngineInstaller,
|
private readonly installer: StoreEngineInstaller,
|
||||||
private readonly stores: InstalledStoreRepository,
|
private readonly stores: InstalledStoreRepository,
|
||||||
private readonly selection: StoreSelectionService
|
private readonly selection: StoreSelectionService,
|
||||||
|
private readonly catalogGateway: StoreCatalogGateway
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public get registryUrl (): string {
|
public get registryUrl (): string {
|
||||||
@@ -47,4 +49,79 @@ export class StoreProvisioningService {
|
|||||||
return this.selection.adoptStore(installed)
|
return this.selection.adoptStore(installed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A catalog the registry does not offer.
|
||||||
|
*
|
||||||
|
* Nothing about installing changes — a record is still a name and a catalog, and the
|
||||||
|
* configuration still comes from the engine's defaults. What differs is only where
|
||||||
|
* the two fields came from, which is why this hands the same record to the same
|
||||||
|
* method rather than growing a second path.
|
||||||
|
*
|
||||||
|
* The name is derived from the host when none is given: it is a label for the picker,
|
||||||
|
* and asking somebody to invent one before they can try a URL is a question with no
|
||||||
|
* useful answer.
|
||||||
|
*/
|
||||||
|
public async installCatalog (
|
||||||
|
catalogUrl: string,
|
||||||
|
name: string | null = null,
|
||||||
|
progress?: EngineProgressListener
|
||||||
|
): Promise<InstalledStore> {
|
||||||
|
const url = normaliseCatalogUrl(catalogUrl)
|
||||||
|
const chosen: RegistryStore = { name: name?.trim() ?? '', catalogUrl: url }
|
||||||
|
return await this.installStore(
|
||||||
|
chosen.name.length > 0 ? chosen : { ...chosen, name: readHostName(url) },
|
||||||
|
progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a store: everything it installed, then the store itself.
|
||||||
|
*
|
||||||
|
* Whichever store is open afterwards is decided by re-reading the disk rather than
|
||||||
|
* guessed at here — removing the open one has to leave the window pointing at
|
||||||
|
* something that exists, and that answer lives in one place.
|
||||||
|
*/
|
||||||
|
public async removeStore (store: InstalledStore, progress?: EngineProgressListener): Promise<void> {
|
||||||
|
await this.catalogGateway.removeStore(store, progress)
|
||||||
|
this.selection.forgetStore(store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What somebody typed, as a URL this can be used as.
|
||||||
|
*
|
||||||
|
* Two liberties taken on purpose, because both are what a person means: a bare host
|
||||||
|
* gets https, and a trailing slash goes. Anything still unparseable is refused here
|
||||||
|
* rather than at the first fetch — a store home written for a bad URL is a directory
|
||||||
|
* somebody has to find and delete.
|
||||||
|
*/
|
||||||
|
function normaliseCatalogUrl (value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length === 0) throw new Error('a catalog address is needed')
|
||||||
|
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(withScheme)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`not a usable address: ${value}`)
|
||||||
|
}
|
||||||
|
// A URL can parse and still have no host — `http://` does. That one used to slip
|
||||||
|
// through and become a store called "http", because the trailing slashes were being
|
||||||
|
// stripped *before* the scheme was checked, turning `http://` into `http:` and then
|
||||||
|
// into `https://http:`.
|
||||||
|
if (parsed.hostname.length === 0) throw new Error(`not a usable address: ${value}`)
|
||||||
|
|
||||||
|
// Rebuilt from the parsed URL rather than from the string: it drops the query and
|
||||||
|
// the fragment — a catalog is a base address, not a request — and settles the
|
||||||
|
// trailing slash in one place instead of at every call site that appends a path.
|
||||||
|
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHostName (catalogUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(catalogUrl).hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return catalogUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export class StoreSelectionService {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The store at this home, or an error naming it. Does not change what is open. */
|
||||||
|
public requireStoreAt (home: string): InstalledStore {
|
||||||
|
const store = this.stores.findByHome(home)
|
||||||
|
if (store === null) throw new StoreMissingError(home)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
public selectStore (home: string): InstalledStore {
|
public selectStore (home: string): InstalledStore {
|
||||||
const store = this.stores.findByHome(home)
|
const store = this.stores.findByHome(home)
|
||||||
if (store === null) throw new StoreMissingError(home)
|
if (store === null) throw new StoreMissingError(home)
|
||||||
@@ -55,6 +62,20 @@ export class StoreSelectionService {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a store that is no longer on the machine.
|
||||||
|
*
|
||||||
|
* The next store is not chosen here: `findCurrentStore` re-reads the disk and applies
|
||||||
|
* the same rule it always does, so "which store is open" has exactly one answer in
|
||||||
|
* one place. Clearing the remembered home first is what stops it choosing the one
|
||||||
|
* that has just been deleted.
|
||||||
|
*/
|
||||||
|
public forgetStore (store: InstalledStore): void {
|
||||||
|
if (this.preferences.readStoreHome() === store.home) this.preferences.forgetStoreHome()
|
||||||
|
if (this.current?.home === store.home) this.current = null
|
||||||
|
this.findCurrentStore()
|
||||||
|
}
|
||||||
|
|
||||||
public readDefaultStoreRoot (): string {
|
public readDefaultStoreRoot (): string {
|
||||||
return this.stores.readRoots()[0] ?? ''
|
return this.stores.readRoots()[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ export interface InstalledStoreRepository {
|
|||||||
/** The roots that are searched, in the order the shell installer would use them. */
|
/** The roots that are searched, in the order the shell installer would use them. */
|
||||||
readRoots: () => readonly string[]
|
readRoots: () => readonly string[]
|
||||||
resolveDefaultHome: (storeId: string) => string
|
resolveDefaultHome: (storeId: string) => string
|
||||||
|
/**
|
||||||
|
* Delete a store home. Only a directory this repository would have *found* is
|
||||||
|
* accepted, so a caller cannot name an arbitrary path and have it removed.
|
||||||
|
*/
|
||||||
|
removeHome: (home: string) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export interface StoreCatalogGateway {
|
|||||||
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
readPaths: (store: InstalledStore, progress?: EngineProgressListener) => Promise<StorePaths>
|
||||||
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
syncGames: (store: InstalledStore, names: readonly string[], progress?: EngineProgressListener) => Promise<void>
|
||||||
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
removeGame: (store: InstalledStore, name: string, progress?: EngineProgressListener) => Promise<void>
|
||||||
|
/**
|
||||||
|
* Take a whole store off this machine: everything it installed, then its own home.
|
||||||
|
*
|
||||||
|
* The games go first and deliberately so. A store's `state.json` is the only record
|
||||||
|
* of what it put where, so deleting the home first would strip the one thing that
|
||||||
|
* knows which payloads, icons and menu entries belong to it — leaving a library of
|
||||||
|
* orphans nothing can ever clean up.
|
||||||
|
*/
|
||||||
|
removeStore: (store: InstalledStore, progress?: EngineProgressListener) => Promise<void>
|
||||||
|
|
||||||
/** Whether this store offers a sign-in, and whether we are holding a token for it. */
|
/** Whether this store offers a sign-in, and whether we are holding a token for it. */
|
||||||
readAccount: (store: InstalledStore) => Promise<StoreAccount>
|
readAccount: (store: InstalledStore) => Promise<StoreAccount>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { SignInPrompt, StoreAccount } from '../../domain/models/StoreAccoun
|
|||||||
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
import { APP_MODE, WEB_MODE, type StoreConfiguration } from '../../domain/models/StoreConfiguration'
|
||||||
import type { StorePaths } from '../../domain/models/StorePaths'
|
import type { StorePaths } from '../../domain/models/StorePaths'
|
||||||
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||||
|
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||||
import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||||
import { StoreFileSystem } from '../files/StoreFileSystem'
|
import { StoreFileSystem } from '../files/StoreFileSystem'
|
||||||
import { CatalogClient, type FetchedCatalog } from './CatalogClient'
|
import { CatalogClient, type FetchedCatalog } from './CatalogClient'
|
||||||
@@ -54,7 +55,10 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
|||||||
* The credentials are injected because they are the host's to keep: on a desktop the
|
* The credentials are injected because they are the host's to keep: on a desktop the
|
||||||
* OS keychain, in the smoke test a map in memory. Nothing here knows which.
|
* OS keychain, in the smoke test a map in memory. Nothing here knows which.
|
||||||
*/
|
*/
|
||||||
public constructor (private readonly credentials: CredentialRepository = NO_CREDENTIALS) {}
|
public constructor (
|
||||||
|
private readonly credentials: CredentialRepository = NO_CREDENTIALS,
|
||||||
|
private readonly stores: InstalledStoreRepository = NO_STORES
|
||||||
|
) {}
|
||||||
|
|
||||||
public async listGames (
|
public async listGames (
|
||||||
store: InstalledStore,
|
store: InstalledStore,
|
||||||
@@ -151,6 +155,34 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a whole store off this machine.
|
||||||
|
*
|
||||||
|
* The order is the whole of it. `state.json` is the only record of what this store
|
||||||
|
* put where — which payload, which icon, which menu entry — so the games have to go
|
||||||
|
* *before* the home does. Delete the home first and every one of those files is an
|
||||||
|
* orphan nothing will ever be able to identify, least of all a later install of the
|
||||||
|
* same store into the same folder.
|
||||||
|
*
|
||||||
|
* The token goes too: a credential for a store that is no longer here is a secret
|
||||||
|
* kept for nothing.
|
||||||
|
*/
|
||||||
|
public removeStore (store: InstalledStore, progress: EngineProgressListener = {}): Promise<void> {
|
||||||
|
const engine = this.openStore(store, progress)
|
||||||
|
const installed = engine.state.readState()
|
||||||
|
const count = installed.size
|
||||||
|
|
||||||
|
engine.installer.purge(engine.layout, installed)
|
||||||
|
engine.state.writeState(installed)
|
||||||
|
engine.launchers.refreshMenu(engine.layout)
|
||||||
|
engine.log(`removed ${String(count)} installed title(s)`)
|
||||||
|
|
||||||
|
this.credentials.clearToken(store.id)
|
||||||
|
this.stores.removeHome(store.home)
|
||||||
|
engine.log(`removed the store home ${store.home}`)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
public async readAccount (store: InstalledStore): Promise<StoreAccount> {
|
public async readAccount (store: InstalledStore): Promise<StoreAccount> {
|
||||||
const engine = this.openStore(store, {})
|
const engine = this.openStore(store, {})
|
||||||
const descriptor = await engine.service.fetchDescriptor()
|
const descriptor = await engine.service.fetchDescriptor()
|
||||||
@@ -375,6 +407,21 @@ function toAccount (descriptor: ServiceDescriptor, token: string | null): StoreA
|
|||||||
return { signInAvailable: available, signedIn: available && token !== null }
|
return { signInAvailable: available, signedIn: available && token !== null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removing a store needs the repository that found it; nothing else here does.
|
||||||
|
*
|
||||||
|
* The default refuses rather than pretending. A gateway assembled without one — the
|
||||||
|
* smoke test — reads catalogs perfectly well, and should say so plainly if somebody
|
||||||
|
* asks it to delete something, instead of silently doing nothing.
|
||||||
|
*/
|
||||||
|
const NO_STORES: InstalledStoreRepository = {
|
||||||
|
findAll: (): readonly [] => [],
|
||||||
|
findByHome: (): null => null,
|
||||||
|
readRoots: (): readonly [] => [],
|
||||||
|
resolveDefaultHome: (storeId: string): string => storeId,
|
||||||
|
removeHome: (): never => { throw new Error('this gateway was built without a store repository') }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A client with nowhere to keep a token is a client that is never signed in.
|
* A client with nowhere to keep a token is a client that is never signed in.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ const CONFIG_FILE_NAME = 'config.json'
|
|||||||
* Finds stores where they were put.
|
* Finds stores where they were put.
|
||||||
*
|
*
|
||||||
* The roots are searched in the shell installers' own order — those homes are still
|
* The roots are searched in the shell installers' own order — those homes are still
|
||||||
* valid stores — and `STORE_ROOT` comes first so a sandbox can be driven without
|
* valid stores. `STORE_ROOT` replaces the lot: a sandbox has to be a sandbox, and it
|
||||||
* touching a working installation, which is how this repository is tested.
|
* only prepended before, so a "sandboxed" run still listed the real stores, could
|
||||||
|
* switch to one, and — now that stores can be removed — could delete one. The README
|
||||||
|
* always said "instead of the real one"; this is the behaviour catching up.
|
||||||
*/
|
*/
|
||||||
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
export class FileSystemInstalledStoreRepository implements InstalledStoreRepository {
|
||||||
public findAll (): readonly InstalledStore[] {
|
public findAll (): readonly InstalledStore[] {
|
||||||
@@ -34,10 +36,11 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
|||||||
}
|
}
|
||||||
|
|
||||||
public readRoots (): readonly string[] {
|
public readRoots (): readonly string[] {
|
||||||
|
const override = process.env['STORE_ROOT']
|
||||||
|
if (override !== undefined && override.length > 0) return [override]
|
||||||
|
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
const roots: string[] = []
|
const roots: string[] = []
|
||||||
const override = process.env['STORE_ROOT']
|
|
||||||
if (override !== undefined && override.length > 0) roots.push(override)
|
|
||||||
const xdgDataHome = process.env['XDG_DATA_HOME']
|
const xdgDataHome = process.env['XDG_DATA_HOME']
|
||||||
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
||||||
roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME))
|
roots.push(path.join(xdgDataHome, STORE_DIRECTORY_NAME))
|
||||||
@@ -58,6 +61,22 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
|||||||
return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`)
|
return path.join(root, `${storeId}${DESKTOP_STORE_ENGINE.homeSuffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a store home, and only one this repository actually found.
|
||||||
|
*
|
||||||
|
* The check is the point. This is the one call in the application that deletes a
|
||||||
|
* directory tree the *window* named, and the window is the least trusted thing here;
|
||||||
|
* resolving the path against what a scan returns means a caller can ask for the
|
||||||
|
* removal of a store, never of a path.
|
||||||
|
*/
|
||||||
|
public removeHome (home: string): void {
|
||||||
|
const known = this.findByHome(home)
|
||||||
|
if (known === null) {
|
||||||
|
throw new Error(`not a store home on this machine: ${home}`)
|
||||||
|
}
|
||||||
|
fs.rmSync(known.home, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
private readDirectories (root: string): readonly string[] {
|
private readDirectories (root: string): readonly string[] {
|
||||||
try {
|
try {
|
||||||
return fs.readdirSync(root, { withFileTypes: true })
|
return fs.readdirSync(root, { withFileTypes: true })
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class ServiceContainer {
|
|||||||
|
|
||||||
const stores = new FileSystemInstalledStoreRepository()
|
const stores = new FileSystemInstalledStoreRepository()
|
||||||
const credentials = new SafeStorageCredentialRepository(environment)
|
const credentials = new SafeStorageCredentialRepository(environment)
|
||||||
const catalogGateway = new NativeStoreCatalogGateway(credentials)
|
const catalogGateway = new NativeStoreCatalogGateway(credentials, stores)
|
||||||
const registry = new HttpStoreRegistryRepository(httpClient)
|
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||||
const installer = new NativeStoreEngineInstaller()
|
const installer = new NativeStoreEngineInstaller()
|
||||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||||
@@ -60,7 +60,9 @@ export class ServiceContainer {
|
|||||||
this.selection = new StoreSelectionService(stores, preferences)
|
this.selection = new StoreSelectionService(stores, preferences)
|
||||||
this.catalog = new CatalogService(catalogGateway, this.selection)
|
this.catalog = new CatalogService(catalogGateway, this.selection)
|
||||||
this.accounts = new AccountService(catalogGateway, this.selection)
|
this.accounts = new AccountService(catalogGateway, this.selection)
|
||||||
this.provisioning = new StoreProvisioningService(registry, installer, stores, this.selection)
|
this.provisioning = new StoreProvisioningService(
|
||||||
|
registry, installer, stores, this.selection, catalogGateway
|
||||||
|
)
|
||||||
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
|
this.launching = new GameLaunchService(new ElectronGameLauncher(shell), this.catalog)
|
||||||
this.state = new ApplicationStateService(
|
this.state = new ApplicationStateService(
|
||||||
preferences, this.selection, this.provisioning, environment
|
preferences, this.selection, this.provisioning, environment
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ export class SelfTestRunner {
|
|||||||
// A gate passes on having something to do, not on having a picker: the picker
|
// 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
|
// 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.
|
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||||
// Both footer icons must be present, named and drawn: refresh and the language
|
// Every footer icon must be named and drawn. There are three — refresh, add a
|
||||||
// picker are the only way to reach those two actions now that neither has a label.
|
// store, and the language picker — and none of them has a label, so an unnamed one
|
||||||
const iconsNamed = report.iconControls.length === 2 &&
|
// is a button nobody can identify and the failure is silent because the glyph still
|
||||||
|
// draws. The count is a floor rather than an equality: a fourth control is somebody
|
||||||
|
// adding one, which this should not fail on; a missing one is what it guards.
|
||||||
|
const iconsNamed = report.iconControls.length >= 3 &&
|
||||||
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
||||||
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
||||||
// version line says there is something newer. Uninstall is always available.
|
// version line says there is something newer. Uninstall is always available.
|
||||||
@@ -196,9 +199,12 @@ export class SelfTestRunner {
|
|||||||
*/
|
*/
|
||||||
private async switchStore (): Promise<StoreSwitchReport> {
|
private async switchStore (): Promise<StoreSwitchReport> {
|
||||||
const record = asRecord(JSON.parse(await this.evaluate(`(async () => {
|
const record = asRecord(JSON.parse(await this.evaluate(`(async () => {
|
||||||
|
// The row is a wrapper now; the part that switches stores is the button inside
|
||||||
|
// it. Clicking the wrapper did nothing at all, and a click that does nothing is
|
||||||
|
// exactly the kind of silent break this test exists for.
|
||||||
const other = [...document.querySelectorAll('#store-list .store-row')]
|
const other = [...document.querySelectorAll('#store-list .store-row')]
|
||||||
.find((row) => !row.classList.contains('is-active'))
|
.find((row) => !row.classList.contains('is-active'))
|
||||||
other.click()
|
other.querySelector('.store-row-open').click()
|
||||||
await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)}))
|
await new Promise((done) => setTimeout(done, ${String(SWITCH_SETTLE_DELAY_MS)}))
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
storeId: document.getElementById('store-id').textContent,
|
storeId: document.getElementById('store-id').textContent,
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ export class StoreIpcController {
|
|||||||
this.handleListRegistry())
|
this.handleListRegistry())
|
||||||
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
|
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
|
||||||
this.handleInstallStore(store))
|
this.handleInstallStore(store))
|
||||||
|
router.handle(IPC_CHANNELS.storeInstallCatalog, async (url: unknown): Promise<InstalledStoreDto> =>
|
||||||
|
this.handleInstallCatalog(requireString(url, 'catalogUrl')))
|
||||||
router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto =>
|
router.handle(IPC_CHANNELS.storeSelectStore, (home: unknown): StoreSelectionDto =>
|
||||||
this.handleSelectStore(requireString(home, 'home')))
|
this.handleSelectStore(requireString(home, 'home')))
|
||||||
|
router.handle(IPC_CHANNELS.storeRemoveStore, async (home: unknown): Promise<void> =>
|
||||||
|
this.handleRemoveStore(requireString(home, 'home')))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +64,39 @@ export class StoreIpcController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A catalog somebody typed, rather than one the registry offered.
|
||||||
|
*
|
||||||
|
* Only the address crosses the bridge. The name is derived from it and the
|
||||||
|
* configuration comes from the engine's defaults, so a typed URL can no more decide
|
||||||
|
* where files land than a registry record can.
|
||||||
|
*/
|
||||||
|
private async handleInstallCatalog (catalogUrl: string): Promise<InstalledStoreDto> {
|
||||||
|
return this.guard.run(async (): Promise<InstalledStoreDto> => {
|
||||||
|
const installed = await this.provisioning.installCatalog(
|
||||||
|
catalogUrl, null, this.streams.asProgressListener()
|
||||||
|
)
|
||||||
|
return this.storeMapper.toDto(installed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private handleSelectStore (home: string): StoreSelectionDto {
|
private handleSelectStore (home: string): StoreSelectionDto {
|
||||||
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
|
return { store: this.storeMapper.toDto(this.selection.selectStore(home)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a store by its home.
|
||||||
|
*
|
||||||
|
* The home is resolved against the stores actually on this machine before anything
|
||||||
|
* is deleted — the window names a store, never a path. Guarded, because it uninstalls
|
||||||
|
* every title the store put here and a second engine call across that would be
|
||||||
|
* working on files this one is removing.
|
||||||
|
*/
|
||||||
|
private async handleRemoveStore (home: string): Promise<void> {
|
||||||
|
await this.guard.run(async (): Promise<void> => {
|
||||||
|
await this.provisioning.removeStore(
|
||||||
|
this.selection.requireStoreAt(home), this.streams.asProgressListener()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,12 @@ const bridge: BridgeApi = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
ipcRenderer.invoke(IPC_CHANNELS.storeListRegistry) as Promise<RegistryResultDto>,
|
||||||
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
installStore: async (store: RegistryStoreDto): Promise<InstalledStoreDto> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>,
|
ipcRenderer.invoke(IPC_CHANNELS.storeInstallStore, store) as Promise<InstalledStoreDto>,
|
||||||
|
installCatalog: async (catalogUrl: string): Promise<InstalledStoreDto> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.storeInstallCatalog, catalogUrl) as Promise<InstalledStoreDto>,
|
||||||
selectStore: async (home: string): Promise<StoreSelectionDto> =>
|
selectStore: async (home: string): Promise<StoreSelectionDto> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>,
|
ipcRenderer.invoke(IPC_CHANNELS.storeSelectStore, home) as Promise<StoreSelectionDto>,
|
||||||
|
removeStore: async (home: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.storeRemoveStore, home) as Promise<void>,
|
||||||
|
|
||||||
openFolder: async (directory: string): Promise<boolean> =>
|
openFolder: async (directory: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
|
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export class RendererApplication {
|
|||||||
})
|
})
|
||||||
this.sideMenu = new SideMenuView({
|
this.sideMenu = new SideMenuView({
|
||||||
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
onSelectStore: (home: string): void => { void this.stores.selectStore(home) },
|
||||||
|
onRemoveStore: (home: string, name: string): void => { void this.stores.removeStore(home, name) },
|
||||||
onAddStore: (): void => { void this.stores.offerStores() },
|
onAddStore: (): void => { void this.stores.offerStores() },
|
||||||
onRefresh: (): void => { void this.catalog.refresh() },
|
onRefresh: (): void => { void this.catalog.refresh() },
|
||||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||||
|
|||||||
@@ -41,11 +41,30 @@ export class StoreController {
|
|||||||
const messages = state.messages
|
const messages = state.messages
|
||||||
const result = await this.bridge.listRegistryStores()
|
const result = await this.bridge.listRegistryStores()
|
||||||
|
|
||||||
|
// A typed address works whatever the registry said — that is the point of it — so
|
||||||
|
// it is attached to every one of the three outcomes below, including the two that
|
||||||
|
// used to be dead ends.
|
||||||
|
const custom = {
|
||||||
|
label: messages.customTitle,
|
||||||
|
hint: messages.customHint,
|
||||||
|
actionLabel: messages.customAction,
|
||||||
|
perform: (catalogUrl: string): void => { void this.installCatalog(catalogUrl) }
|
||||||
|
}
|
||||||
|
// Only where there is something to go back to. On a machine with no store the grid
|
||||||
|
// behind this screen is empty, and "Cancel" would lead nowhere. Spread rather than
|
||||||
|
// an `undefined` value: the strict optional-property rule treats "absent" and
|
||||||
|
// "present but undefined" as different things, and here they genuinely are.
|
||||||
|
const cancel = state.currentStore === null
|
||||||
|
? {}
|
||||||
|
: { cancel: { label: messages.cancel, perform: (): void => { this.store.applyGate(null) } } }
|
||||||
|
|
||||||
if (result.error !== null) {
|
if (result.error !== null) {
|
||||||
this.store.applyGate({
|
this.store.applyGate({
|
||||||
title: messages.registryFailed,
|
title: messages.registryFailed,
|
||||||
body: `${result.sourceUrl}\n\n${result.error}`,
|
body: `${result.sourceUrl}\n\n${result.error}`,
|
||||||
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } }
|
action: { label: messages.registryRetry, perform: (): void => { void this.offerStores() } },
|
||||||
|
custom,
|
||||||
|
...cancel
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -53,7 +72,9 @@ export class StoreController {
|
|||||||
if (result.stores.length === 0) {
|
if (result.stores.length === 0) {
|
||||||
this.store.applyGate({
|
this.store.applyGate({
|
||||||
title: messages.setupTitle,
|
title: messages.setupTitle,
|
||||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`,
|
||||||
|
custom,
|
||||||
|
...cancel
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,10 +88,65 @@ export class StoreController {
|
|||||||
if (chosen !== null) void this.installStore(chosen)
|
if (chosen !== null) void this.installStore(chosen)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
choices: result.stores
|
choices: result.stores,
|
||||||
|
custom,
|
||||||
|
...cancel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a store off this machine, with what that costs stated first.
|
||||||
|
*
|
||||||
|
* The confirmation names the number of installed titles, because that is the part
|
||||||
|
* somebody would not otherwise expect: removing a store uninstalls everything it
|
||||||
|
* put here. Leaving them behind would be worse — the store's state file is the only
|
||||||
|
* record of which files belong to it, so orphans would be permanent.
|
||||||
|
*/
|
||||||
|
public async removeStore (home: string, name: string): Promise<void> {
|
||||||
|
const state = this.store.readState()
|
||||||
|
const messages = state.messages
|
||||||
|
const installed = state.currentStore?.home === home
|
||||||
|
? state.games.filter((game): boolean => game.installed).length
|
||||||
|
: null
|
||||||
|
|
||||||
|
const question = installed === null
|
||||||
|
? messages.removeStoreConfirm.replace('%{store}', name)
|
||||||
|
: messages.removeStoreConfirmGames
|
||||||
|
.replace('%{store}', name)
|
||||||
|
.replace('%{count}', String(installed))
|
||||||
|
if (!window.confirm(question)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bridge.removeStore(home)
|
||||||
|
this.store.applyAppState(await this.bridge.readState())
|
||||||
|
const remaining = this.store.readState().currentStore
|
||||||
|
this.store.applyGate(null)
|
||||||
|
if (remaining === null) {
|
||||||
|
await this.offerStores()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.catalog.refresh()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installCatalog (catalogUrl: string): Promise<void> {
|
||||||
|
const messages = this.store.readState().messages
|
||||||
|
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||||
|
try {
|
||||||
|
await this.bridge.installCatalog(catalogUrl)
|
||||||
|
this.store.applyAppState(await this.bridge.readState())
|
||||||
|
this.store.applyGate(null)
|
||||||
|
await this.catalog.refresh()
|
||||||
|
await this.catalog.syncGames([])
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.log.appendLine(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
this.store.applyProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
private async installStore (chosen: RegistryStoreDto): Promise<void> {
|
||||||
const messages = this.store.readState().messages
|
const messages = this.store.readState().messages
|
||||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||||
|
|||||||
+28
-1
@@ -33,7 +33,6 @@
|
|||||||
<section class="side-block">
|
<section class="side-block">
|
||||||
<h2 class="side-head" id="head-stores"></h2>
|
<h2 class="side-head" id="head-stores"></h2>
|
||||||
<div class="store-list" id="store-list"></div>
|
<div class="store-list" id="store-list"></div>
|
||||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -66,6 +65,17 @@
|
|||||||
<path d="M13.5 2v3h-3" />
|
<path d="M13.5 2v3h-3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
Adding a store sits beside Refresh rather than under the store list: both
|
||||||
|
are actions on the whole store rather than on one of them, and a full-width
|
||||||
|
button under the list read as a third store.
|
||||||
|
-->
|
||||||
|
<button id="add-store" class="icon-btn">
|
||||||
|
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M8 3v10" />
|
||||||
|
<path d="M3 8h10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<!--
|
<!--
|
||||||
The select is still a real `<select>`, stretched over the icon and invisible:
|
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
|
the native dropdown knows how to open upward in a cramped window and is
|
||||||
@@ -111,8 +121,25 @@
|
|||||||
<select id="gate-select" class="select"></select>
|
<select id="gate-select" class="select"></select>
|
||||||
</label>
|
</label>
|
||||||
<button id="gate-action" class="btn btn-primary" hidden></button>
|
<button id="gate-action" class="btn btn-primary" hidden></button>
|
||||||
|
<button id="gate-cancel" class="btn btn-ghost" hidden></button>
|
||||||
<a id="gate-link" class="link" href="#" hidden></a>
|
<a id="gate-link" class="link" href="#" hidden></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A catalog the registry does not list. Its own row under the picker rather
|
||||||
|
than a third option inside it: choosing from a list and typing an address are
|
||||||
|
different gestures, and a <select> entry that turns into a text field is a
|
||||||
|
control that lies about what it is.
|
||||||
|
-->
|
||||||
|
<div id="gate-custom" class="gate-custom" hidden>
|
||||||
|
<span id="gate-custom-label" class="gate-custom-label"></span>
|
||||||
|
<div class="gate-custom-row">
|
||||||
|
<input id="gate-custom-url" class="input" type="url" spellcheck="false"
|
||||||
|
autocapitalize="off" autocorrect="off">
|
||||||
|
<button id="gate-custom-action" class="btn btn-secondary"></button>
|
||||||
|
</div>
|
||||||
|
<p id="gate-custom-hint" class="gate-custom-hint"></p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main id="grid" class="grid" hidden></main>
|
<main id="grid" class="grid" hidden></main>
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export interface GateLink {
|
|||||||
readonly url: string
|
readonly url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A catalog typed rather than chosen. Absent where typing one makes no sense. */
|
||||||
|
export interface GateCustom {
|
||||||
|
readonly label: string
|
||||||
|
readonly hint: string
|
||||||
|
readonly actionLabel: string
|
||||||
|
readonly perform: (catalogUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the gate is showing.
|
* What the gate is showing.
|
||||||
*
|
*
|
||||||
@@ -24,4 +32,12 @@ export interface GatePresentation {
|
|||||||
readonly action?: GateAction
|
readonly action?: GateAction
|
||||||
readonly link?: GateLink
|
readonly link?: GateLink
|
||||||
readonly choices?: readonly RegistryStoreDto[]
|
readonly choices?: readonly RegistryStoreDto[]
|
||||||
|
readonly custom?: GateCustom
|
||||||
|
/**
|
||||||
|
* A way back, shown only when there is somewhere to go back *to*.
|
||||||
|
*
|
||||||
|
* Without it the picker was a trap: opening it with a store already installed left
|
||||||
|
* no way to reach the grid again short of installing something.
|
||||||
|
*/
|
||||||
|
readonly cancel?: GateAction
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-9
@@ -113,26 +113,44 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
}
|
}
|
||||||
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
|
.nav-toggle:hover { color: var(--ink); border-color: #3a4757; }
|
||||||
|
|
||||||
/* Store switcher: one row per store on this machine, the open one marked. */
|
/*
|
||||||
|
* Store switcher: one row per store on this machine, the open one marked.
|
||||||
|
*
|
||||||
|
* The row is a wrapper holding two buttons — open, and remove — rather than being a
|
||||||
|
* button itself: a button inside a button is invalid markup, and the inner click would
|
||||||
|
* reach the outer handler anyway. So the framing lives on the wrapper and the padding
|
||||||
|
* on the part that is actually clicked, or the click target would be smaller than the
|
||||||
|
* thing it looks like.
|
||||||
|
*/
|
||||||
.store-list { display: flex; flex-direction: column; gap: 4px; }
|
.store-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.store-row {
|
.store-row {
|
||||||
font: inherit;
|
display: flex;
|
||||||
text-align: left;
|
align-items: stretch;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.store-row:hover { background: var(--panel-2); }
|
||||||
|
.store-row.is-active {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-color: #2f5a49;
|
||||||
|
}
|
||||||
|
.store-row-open {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
.store-row:hover:not(:disabled) { background: var(--panel-2); }
|
|
||||||
.store-row.is-active {
|
|
||||||
background: var(--panel-2);
|
|
||||||
border-color: #2f5a49;
|
|
||||||
}
|
|
||||||
.store-row .store-row-name { font-weight: 600; }
|
.store-row .store-row-name { font-weight: 600; }
|
||||||
.store-row .store-row-id {
|
.store-row .store-row-id {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -141,7 +159,30 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.store-row:disabled { opacity: .55; cursor: default; }
|
/*
|
||||||
|
* Remove appears on hover or focus only. It is destructive, and it sits in a list
|
||||||
|
* whose ordinary use is switching stores — it should not be under the pointer of
|
||||||
|
* every routine click. :focus-within is what keeps it reachable by keyboard.
|
||||||
|
*/
|
||||||
|
.store-row-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms, color 120ms;
|
||||||
|
}
|
||||||
|
.store-row:hover .store-row-remove,
|
||||||
|
.store-row:focus-within .store-row-remove { opacity: 1; }
|
||||||
|
.store-row-remove:hover { color: var(--warn); }
|
||||||
|
.store-row-open:disabled { opacity: .55; cursor: default; }
|
||||||
|
.store-row-remove:disabled { opacity: 0; cursor: default; }
|
||||||
|
.icon-trash { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* Categories: what the catalog is filtered down to. */
|
/* Categories: what the catalog is filtered down to. */
|
||||||
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
|
.cats { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; }
|
||||||
@@ -259,6 +300,13 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||||
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
|
.btn-primary:hover:not(:disabled) { background: #45cd9b; }
|
||||||
.btn-ghost { background: transparent; color: var(--ink-dim); }
|
.btn-ghost { background: transparent; color: var(--ink-dim); }
|
||||||
|
/*
|
||||||
|
* The second-choice button: present and pressable, but not the one the eye lands on.
|
||||||
|
* Used where a card offers signing in rather than installing, and beside the typed
|
||||||
|
* catalog address — both are real actions that are not the primary one.
|
||||||
|
*/
|
||||||
|
.btn-secondary { background: transparent; border-color: var(--line); color: var(--ink); }
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: var(--panel-2); border-color: #3a4757; }
|
||||||
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
.btn-tiny { padding: 3px 9px; font-size: 12px; font-weight: 500; }
|
||||||
.select {
|
.select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -333,6 +381,39 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
|||||||
the machine here. */
|
the machine here. */
|
||||||
.card.is-purchasable { opacity: 1; }
|
.card.is-purchasable { opacity: 1; }
|
||||||
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
|
.side-quiet-text { color: var(--ink-dim); font-size: 12px; margin: 0 0 8px; }
|
||||||
|
|
||||||
|
/* --- the gate's typed-address row ---------------------------------------- */
|
||||||
|
.gate-custom {
|
||||||
|
margin: 22px auto 0;
|
||||||
|
max-width: 460px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.gate-custom-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.gate-custom-row { display: flex; gap: 8px; }
|
||||||
|
.gate-custom-row .input { flex: 1; min-width: 0; }
|
||||||
|
.gate-custom-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
.gate { overflow-y: auto; }
|
.gate { overflow-y: auto; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||||
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
import type { MessageBundle } from '../../shared/i18n/MessageBundle'
|
||||||
import type { GateLink, GatePresentation } from '../state/GatePresentation'
|
import type { GateCustom, GateLink, GatePresentation } from '../state/GatePresentation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
* The screen shown instead of the grid when there is nothing to drive: no Python, no
|
||||||
@@ -15,7 +15,13 @@ export class GateView {
|
|||||||
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||||
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||||
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||||
|
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
|
||||||
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
private readonly link = requireElement('gate-link', HTMLAnchorElement)
|
||||||
|
private readonly custom = requireElement('gate-custom', HTMLElement)
|
||||||
|
private readonly customLabel = requireElement('gate-custom-label', HTMLElement)
|
||||||
|
private readonly customUrl = requireElement('gate-custom-url', HTMLInputElement)
|
||||||
|
private readonly customAction = requireElement('gate-custom-action', HTMLButtonElement)
|
||||||
|
private readonly customHint = requireElement('gate-custom-hint', HTMLElement)
|
||||||
|
|
||||||
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
public constructor (private readonly onOpenUrl: (url: string) => void) {}
|
||||||
|
|
||||||
@@ -25,6 +31,8 @@ export class GateView {
|
|||||||
setText(this.body, presentation.body)
|
setText(this.body, presentation.body)
|
||||||
this.renderChoices(presentation.choices ?? [], messages)
|
this.renderChoices(presentation.choices ?? [], messages)
|
||||||
this.renderAction(presentation)
|
this.renderAction(presentation)
|
||||||
|
this.renderCancel(presentation)
|
||||||
|
this.renderCustom(presentation.custom ?? null)
|
||||||
this.renderLink(presentation.link ?? null)
|
this.renderLink(presentation.link ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +65,40 @@ export class GateView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderCancel (presentation: GatePresentation): void {
|
||||||
|
const cancel = presentation.cancel
|
||||||
|
setHidden(this.cancel, cancel === undefined)
|
||||||
|
if (cancel === undefined) return
|
||||||
|
setText(this.cancel, cancel.label)
|
||||||
|
this.cancel.onclick = (): void => { cancel.perform(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed-address row.
|
||||||
|
*
|
||||||
|
* Enter submits as well as the button, because a single text field with a button
|
||||||
|
* beside it is a form, and a form that ignores Enter is a small daily annoyance.
|
||||||
|
*/
|
||||||
|
private renderCustom (custom: GateCustom | null): void {
|
||||||
|
setHidden(this.custom, custom === null)
|
||||||
|
if (custom === null) return
|
||||||
|
setText(this.customLabel, custom.label)
|
||||||
|
setText(this.customHint, custom.hint)
|
||||||
|
setText(this.customAction, custom.actionLabel)
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
const value = this.customUrl.value.trim()
|
||||||
|
if (value.length === 0) return
|
||||||
|
custom.perform(value)
|
||||||
|
}
|
||||||
|
this.customAction.onclick = submit
|
||||||
|
this.customUrl.onkeydown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private renderLink (link: GateLink | null): void {
|
private renderLink (link: GateLink | null): void {
|
||||||
setHidden(this.link, link === null)
|
setHidden(this.link, link === null)
|
||||||
if (link === null) return
|
if (link === null) return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { AppState } from '../state/AppStore'
|
|||||||
|
|
||||||
export interface SideMenuViewCallbacks {
|
export interface SideMenuViewCallbacks {
|
||||||
readonly onSelectStore: (home: string) => void
|
readonly onSelectStore: (home: string) => void
|
||||||
|
readonly onRemoveStore: (home: string, name: string) => void
|
||||||
readonly onAddStore: () => void
|
readonly onAddStore: () => void
|
||||||
readonly onRefresh: () => void
|
readonly onRefresh: () => void
|
||||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||||
@@ -57,7 +58,9 @@ export class SideMenuView {
|
|||||||
public render (state: AppState): void {
|
public render (state: AppState): void {
|
||||||
setText(this.storesHead, state.messages.stores)
|
setText(this.storesHead, state.messages.stores)
|
||||||
setText(this.categoriesHead, state.messages.categories)
|
setText(this.categoriesHead, state.messages.categories)
|
||||||
setText(this.addStore, state.messages.addStore)
|
// Add is an icon now, beside Refresh: naming it is all the view does, and writing
|
||||||
|
// text into it would replace the glyph.
|
||||||
|
describeControl(this.addStore, state.messages.addStoreHint)
|
||||||
// Refresh and the language picker are icons: naming them is all the view does, and
|
// Refresh and the language picker are icons: naming them is all the view does, and
|
||||||
// writing text into them would replace the glyph.
|
// writing text into them would replace the glyph.
|
||||||
describeControl(this.refresh, state.messages.refresh)
|
describeControl(this.refresh, state.messages.refresh)
|
||||||
@@ -84,15 +87,30 @@ export class SideMenuView {
|
|||||||
.map((store: InstalledStoreDto): string => store.id))
|
.map((store: InstalledStoreDto): string => store.id))
|
||||||
|
|
||||||
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
|
this.storeList.replaceChildren(...state.stores.map((store: InstalledStoreDto): HTMLElement => {
|
||||||
const row = createElement('button', 'store-row')
|
// A row is a button *and* carries one; nesting them would be invalid markup and
|
||||||
|
// the inner click would reach the outer handler anyway. So the row is a wrapper
|
||||||
|
// with two buttons in it: switch, and remove.
|
||||||
|
const row = createElement('div', 'store-row')
|
||||||
if (store.home === activeHome) row.classList.add('is-active')
|
if (store.home === activeHome) row.classList.add('is-active')
|
||||||
row.appendChild(createElement('span', 'store-row-name', store.name))
|
|
||||||
row.appendChild(createElement('span', 'store-row-id',
|
const open = createElement('button', 'store-row-open')
|
||||||
|
open.appendChild(createElement('span', 'store-row-name', store.name))
|
||||||
|
open.appendChild(createElement('span', 'store-row-id',
|
||||||
ambiguousIds.has(store.id) ? store.home : store.id))
|
ambiguousIds.has(store.id) ? store.home : store.id))
|
||||||
row.title = store.home
|
open.title = store.home
|
||||||
row.addEventListener('click', (): void => {
|
open.addEventListener('click', (): void => {
|
||||||
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
|
if (store.home !== activeHome) this.callbacks.onSelectStore(store.home)
|
||||||
})
|
})
|
||||||
|
row.appendChild(open)
|
||||||
|
|
||||||
|
const remove = createElement('button', 'store-row-remove')
|
||||||
|
remove.appendChild(createTrashIcon())
|
||||||
|
describeControl(remove, state.messages.removeStore)
|
||||||
|
remove.addEventListener('click', (): void => {
|
||||||
|
this.callbacks.onRemoveStore(store.home, store.name)
|
||||||
|
})
|
||||||
|
row.appendChild(remove)
|
||||||
|
|
||||||
return row
|
return row
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -168,6 +186,26 @@ export class SideMenuView {
|
|||||||
* `title` is the tooltip a mouse finds and `aria-label` is what a screen reader reads;
|
* `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.
|
* an icon button needs both, and they are the same sentence.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The remove glyph: a lid and a bin.
|
||||||
|
*
|
||||||
|
* Drawn rather than a character, for the same reason the other two icons are — a font
|
||||||
|
* that lacks the symbol shows a box, and this button has no text to fall back on.
|
||||||
|
*/
|
||||||
|
function createTrashIcon (): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
svg.setAttribute('class', 'icon icon-trash')
|
||||||
|
svg.setAttribute('viewBox', '0 0 16 16')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
svg.setAttribute('focusable', 'false')
|
||||||
|
for (const d of ['M3 4.5h10', 'M6.5 4.5V3h3v1.5', 'M4.5 4.5 5 13h6l.5-8.5', 'M6.8 7v3.5', 'M9.2 7v3.5']) {
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||||
|
path.setAttribute('d', d)
|
||||||
|
svg.appendChild(path)
|
||||||
|
}
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
function describeControl (element: HTMLElement, name: string): void {
|
function describeControl (element: HTMLElement, name: string): void {
|
||||||
element.title = name
|
element.title = name
|
||||||
element.setAttribute('aria-label', name)
|
element.setAttribute('aria-label', name)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { StoreProvisioningService } from '../application/services/StoreProvisioningService'
|
||||||
|
import { StoreSelectionService } from '../application/services/StoreSelectionService'
|
||||||
|
import { PreferencesService } from '../application/services/PreferencesService'
|
||||||
|
import type { InstalledStore } from '../domain/models/InstalledStore'
|
||||||
|
import type { ApplicationEnvironment } from '../domain/ports/ApplicationEnvironment'
|
||||||
|
import type { StoreRegistryRepository } from '../domain/ports/StoreRegistryRepository'
|
||||||
|
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
|
||||||
|
import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository'
|
||||||
|
import { NativeStoreEngineInstaller } from '../infrastructure/repositories/NativeStoreEngineInstaller'
|
||||||
|
import { JsonFilePreferencesRepository } from '../infrastructure/repositories/JsonFilePreferencesRepository'
|
||||||
|
import { asRecord } from '../infrastructure/json/JsonRecord'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adding a store and taking it away again, in a sandbox.
|
||||||
|
*
|
||||||
|
* This exists because removal is the only code in the application that deletes a
|
||||||
|
* directory tree, and the path it deletes is named by the window — the least trusted
|
||||||
|
* thing here. A mistake in it is not a rendering glitch; it is somebody's files. The
|
||||||
|
* smoke test cannot cover it, since it runs against the real machine and would have to
|
||||||
|
* delete a real store to prove anything.
|
||||||
|
*
|
||||||
|
* Everything happens under a temporary root: `STORE_ROOT` is what the repository looks
|
||||||
|
* at first, so nothing outside it is even visible to this run.
|
||||||
|
*
|
||||||
|
* npm run storetest
|
||||||
|
*/
|
||||||
|
class StoreLifecycleTest {
|
||||||
|
private failed = false
|
||||||
|
|
||||||
|
private readonly root = fs.mkdtempSync(path.join(os.tmpdir(), 'warp-store-lifecycle-'))
|
||||||
|
private readonly stores: FileSystemInstalledStoreRepository
|
||||||
|
private readonly provisioning: StoreProvisioningService
|
||||||
|
private readonly selection: StoreSelectionService
|
||||||
|
|
||||||
|
public constructor () {
|
||||||
|
// Before the repository is built: the roots are read from the environment, and
|
||||||
|
// this is what keeps the run inside the sandbox.
|
||||||
|
process.env['STORE_ROOT'] = this.root
|
||||||
|
|
||||||
|
this.stores = new FileSystemInstalledStoreRepository()
|
||||||
|
const preferences = new PreferencesService(
|
||||||
|
new JsonFilePreferencesRepository(sandboxEnvironment(this.root)), sandboxEnvironment(this.root)
|
||||||
|
)
|
||||||
|
this.selection = new StoreSelectionService(this.stores, preferences)
|
||||||
|
this.provisioning = new StoreProvisioningService(
|
||||||
|
emptyRegistry(),
|
||||||
|
new NativeStoreEngineInstaller(),
|
||||||
|
this.stores,
|
||||||
|
this.selection,
|
||||||
|
new NativeStoreCatalogGateway(undefined, this.stores)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async run (): Promise<number> {
|
||||||
|
console.log('warp-engine-client store lifecycle test')
|
||||||
|
console.log(` .. sandbox: ${this.root}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = await this.checkAddByUrl()
|
||||||
|
await this.checkRemove(store)
|
||||||
|
await this.checkRemoveRefusesAnythingElse()
|
||||||
|
await this.checkNormalisation()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.report(false, 'run', error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(this.root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
return this.failed ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A typed address becomes a store, with its identity derived from the URL. */
|
||||||
|
private async checkAddByUrl (): Promise<InstalledStore> {
|
||||||
|
const store = await this.provisioning.installCatalog('orbit.teletypegames.org')
|
||||||
|
|
||||||
|
// https assumed, name from the host, id from the host's first label — none of it
|
||||||
|
// supplied, all of it derived, which is what makes a bare address enough to type.
|
||||||
|
this.report(store.id === 'orbit', 'id from the host', store.id)
|
||||||
|
this.report(store.name === 'orbit.teletypegames.org', 'name from the host', store.name)
|
||||||
|
this.report(fs.existsSync(store.configPath), 'config written', store.configPath)
|
||||||
|
this.report(
|
||||||
|
readBaseUrl(store.configPath) === 'https://orbit.teletypegames.org',
|
||||||
|
'https assumed', readBaseUrl(store.configPath)
|
||||||
|
)
|
||||||
|
this.report(store.home.startsWith(this.root), 'inside the sandbox', store.home)
|
||||||
|
this.report(this.stores.findAll().length === 1, 'found by a scan', String(this.stores.findAll().length))
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
/** And it goes away again, home and all. */
|
||||||
|
private async checkRemove (store: InstalledStore): Promise<void> {
|
||||||
|
await this.provisioning.removeStore(store)
|
||||||
|
|
||||||
|
this.report(!fs.existsSync(store.home), 'home removed', store.home)
|
||||||
|
this.report(this.stores.findAll().length === 0, 'gone from the scan')
|
||||||
|
this.report(this.selection.findCurrentStore() === null, 'nothing open afterwards')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guard: a path that is not a store home on this machine is refused.
|
||||||
|
*
|
||||||
|
* This is the one that matters. The window names what to remove, so the only thing
|
||||||
|
* standing between a typo — or something worse — and `rm -rf` is that the path has to
|
||||||
|
* resolve to a store a scan actually found.
|
||||||
|
*/
|
||||||
|
private async checkRemoveRefusesAnythingElse (): Promise<void> {
|
||||||
|
const bystander = path.join(this.root, 'not-a-store')
|
||||||
|
fs.mkdirSync(bystander, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(bystander, 'keep-me.txt'), 'important')
|
||||||
|
|
||||||
|
const fake: InstalledStore = {
|
||||||
|
id: 'fake', name: 'fake', home: bystander,
|
||||||
|
configPath: path.join(bystander, 'config.json'), engine: 'desktop'
|
||||||
|
}
|
||||||
|
let refused = false
|
||||||
|
try {
|
||||||
|
await this.provisioning.removeStore(fake)
|
||||||
|
} catch {
|
||||||
|
refused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.report(refused, 'refuses a path that is not a store')
|
||||||
|
this.report(fs.existsSync(path.join(bystander, 'keep-me.txt')), 'left the bystander alone')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is simply not an address is refused before anything is written.
|
||||||
|
*
|
||||||
|
* Refusing early is the point: a store home created for an unusable URL is a
|
||||||
|
* directory somebody has to find and delete by hand, and they would have no idea
|
||||||
|
* why it was there.
|
||||||
|
*/
|
||||||
|
private async checkNormalisation (): Promise<void> {
|
||||||
|
const accepted: string[] = []
|
||||||
|
for (const value of [ '', ' ', 'http://', 'not a url at all' ]) {
|
||||||
|
try {
|
||||||
|
const store = await this.provisioning.installCatalog(value)
|
||||||
|
accepted.push(`${JSON.stringify(value)} -> ${store.home}`)
|
||||||
|
} catch {
|
||||||
|
// Refused, which is what should happen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.report(accepted.length === 0, 'refuses what is not an address', accepted.join(', '))
|
||||||
|
this.report(this.stores.findAll().length === 0, 'and wrote nothing while refusing')
|
||||||
|
}
|
||||||
|
|
||||||
|
private report (passed: boolean, what: string, detail: string = ''): void {
|
||||||
|
if (!passed) this.failed = true
|
||||||
|
console.log(` ${passed ? 'ok ' : 'FAIL'} ${what}${detail === '' ? '' : `: ${detail}`}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registry with nothing in it: this test is about the typed path, not the listed one. */
|
||||||
|
function emptyRegistry (): StoreRegistryRepository {
|
||||||
|
return {
|
||||||
|
sourceUrl: 'about:blank',
|
||||||
|
listStores: (): Promise<readonly []> => Promise.resolve([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sandboxEnvironment (root: string): ApplicationEnvironment {
|
||||||
|
return {
|
||||||
|
readVersion: (): string => '0.0.0-test',
|
||||||
|
readSystemLocale: (): string => 'en',
|
||||||
|
resolveUserDataPath: (fileName: string): string => path.join(root, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base URL out of the config the installer wrote.
|
||||||
|
*
|
||||||
|
* Read through an index signature rather than a typed shape: the file is snake_case —
|
||||||
|
* that is the format the store repositories publish and the shell engine wrote — and
|
||||||
|
* naming the key in a type here would be the one place in this codebase declaring a
|
||||||
|
* property the linter would then have to be told to allow.
|
||||||
|
*/
|
||||||
|
function readBaseUrl (configPath: string): string {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||||
|
const store = asRecord(asRecord(parsed)?.['store'])
|
||||||
|
const baseUrl = store?.['base_url']
|
||||||
|
return typeof baseUrl === 'string' ? baseUrl : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void new StoreLifecycleTest().run().then((code: number): void => { process.exitCode = code })
|
||||||
@@ -39,7 +39,11 @@ export interface BridgeApi {
|
|||||||
|
|
||||||
listRegistryStores: () => Promise<RegistryResultDto>
|
listRegistryStores: () => Promise<RegistryResultDto>
|
||||||
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
||||||
|
/** A catalog the registry does not list; the name is derived from the address. */
|
||||||
|
installCatalog: (catalogUrl: string) => Promise<InstalledStoreDto>
|
||||||
selectStore: (home: string) => Promise<StoreSelectionDto>
|
selectStore: (home: string) => Promise<StoreSelectionDto>
|
||||||
|
/** Uninstall everything the store put here, then remove the store itself. */
|
||||||
|
removeStore: (home: string) => Promise<void>
|
||||||
|
|
||||||
openFolder: (directory: string) => Promise<boolean>
|
openFolder: (directory: string) => Promise<boolean>
|
||||||
openUrl: (url: string) => Promise<boolean>
|
openUrl: (url: string) => Promise<boolean>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export const IPC_CHANNELS = {
|
|||||||
|
|
||||||
storeListRegistry: 'store:listRegistry',
|
storeListRegistry: 'store:listRegistry',
|
||||||
storeInstallStore: 'store:installStore',
|
storeInstallStore: 'store:installStore',
|
||||||
|
storeInstallCatalog: 'store:installCatalog',
|
||||||
storeSelectStore: 'store:selectStore',
|
storeSelectStore: 'store:selectStore',
|
||||||
|
storeRemoveStore: 'store:removeStore',
|
||||||
|
|
||||||
/** Main to renderer, one way. */
|
/** Main to renderer, one way. */
|
||||||
streamLog: 'stream:log',
|
streamLog: 'stream:log',
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export const ENGLISH_MESSAGES = {
|
|||||||
menu: 'Menu',
|
menu: 'Menu',
|
||||||
stores: 'Stores',
|
stores: 'Stores',
|
||||||
addStore: 'Add a store…',
|
addStore: 'Add a store…',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
addStoreHint: 'Add a store',
|
||||||
|
customTitle: 'Or a catalog of your own',
|
||||||
|
customHint: 'Any WarpEngine catalog. The name is taken from the address; https is assumed when you leave the scheme out.',
|
||||||
|
customAction: 'Add',
|
||||||
|
removeStore: 'Remove this store',
|
||||||
|
removeStoreConfirm: 'Remove %{store}? Its settings and its record of what it installed are deleted.',
|
||||||
|
removeStoreConfirmGames: 'Remove %{store}? This uninstalls the %{count} title(s) it put on this machine and deletes its menu entries.',
|
||||||
switchFailed: 'That store could not be opened',
|
switchFailed: 'That store could not be opened',
|
||||||
categories: 'Categories',
|
categories: 'Categories',
|
||||||
catAll: 'Everything',
|
catAll: 'Everything',
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
|||||||
menu: 'Menü',
|
menu: 'Menü',
|
||||||
stores: 'Store-ok',
|
stores: 'Store-ok',
|
||||||
addStore: 'Store hozzáadása…',
|
addStore: 'Store hozzáadása…',
|
||||||
|
cancel: 'Mégsem',
|
||||||
|
addStoreHint: 'Bolt hozzáadása',
|
||||||
|
customTitle: 'Vagy egy saját katalógus',
|
||||||
|
customHint: 'Bármelyik WarpEngine-katalógus. A nevet a címből vesszük; séma nélkül https-t feltételezünk.',
|
||||||
|
customAction: 'Hozzáadás',
|
||||||
|
removeStore: 'Bolt eltávolítása',
|
||||||
|
removeStoreConfirm: 'Eltávolítod a(z) %{store} boltot? A beállításai és a nyilvántartása a telepítettekről törlődik.',
|
||||||
|
removeStoreConfirmGames: 'Eltávolítod a(z) %{store} boltot? Ezzel eltávolul az a(z) %{count} cím is, amit erre a gépre tett, a menüelemeikkel együtt.',
|
||||||
switchFailed: 'Ez a store nem nyitható meg',
|
switchFailed: 'Ez a store nem nyitható meg',
|
||||||
categories: 'Kategóriák',
|
categories: 'Kategóriák',
|
||||||
catAll: 'Minden',
|
catAll: 'Minden',
|
||||||
|
|||||||
Reference in New Issue
Block a user