Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6285d93790 | ||
|
|
255c588cbd |
@@ -36,7 +36,7 @@ BUILDER_ARGS := $(if $(STORES_API),-- --config.extraMetadata.warpEngine.registry
|
||||
|
||||
.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
|
||||
|
||||
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
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
+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
|
||||
it.** Where a store sells things, a card shows the **price** and a **Buy** button that
|
||||
opens the store's own page; where you own it, an **Install** as before. Two new
|
||||
categories go with it — **Owned** and **To buy** — because owning something is not the
|
||||
same as having installed it.
|
||||
**A store can be taken off the machine again.** Hovering a store in the side menu shows
|
||||
a bin. It asks first, and says how many titles will go with it — because removing a
|
||||
store *uninstalls what it installed*. 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.
|
||||
|
||||
**Signing in, without a password ever reaching this window.** The side menu grows an
|
||||
**Account** block, and signing in shows a short code: your browser opens on the store's
|
||||
own page and you type it there. That detour is the point — a desktop application asking
|
||||
for a password is a desktop application people should not be giving one to. The token
|
||||
lives in the OS keychain (Keychain, libsecret, DPAPI), one per store, and *Sign out*
|
||||
revokes it at the server as well as forgetting it here.
|
||||
**Adding one moved to a + beside Refresh**, and it now takes a catalog address of your
|
||||
own as well as the ones the registry lists. A bare host is enough — `https` is assumed —
|
||||
and the name is taken from the address. Both are actions on the whole store rather than
|
||||
on one of them, which is why they sit together; the full-width "Add a store…" button
|
||||
under the list read as a third store.
|
||||
|
||||
**None of this is knowledge about any particular store.** It all arrives from the
|
||||
catalog's own server: WarpEngine 0.5 answers `GET /api/service` with what it offers, and
|
||||
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.
|
||||
**The picker has a way out.** Opening it with a store already installed used to be a
|
||||
trap: the grid was replaced and nothing short of installing something brought it back.
|
||||
|
||||
**A store with no sign-in shows none.** Every WarpEngine before 0.5 has no descriptor at
|
||||
all, and a 0.5 store that sells nothing reports none either. Both read as "a plain
|
||||
catalog", which is what this client assumed for its whole life until now, and the window
|
||||
behaves accordingly: no Account block, no prices, no new categories.
|
||||
**Two things found by the new test.** `make storetest` adds and removes a store in a
|
||||
sandbox, because removal is the only code here that deletes a directory tree and the
|
||||
path it deletes is named by the window. It immediately caught that `http://` was
|
||||
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
|
||||
what this *machine* cannot do — an unsupported platform, no build for this architecture
|
||||
— 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.
|
||||
**`btn-secondary` had no styling at all.** It was introduced in 2.4.0 on the card's
|
||||
sign-in button and on the sign-in panel, and rendered as a plain button in both places.
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "warp-engine-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.",
|
||||
"license": "MIT",
|
||||
"author": "Teletype Games <games@teletype.hu>",
|
||||
@@ -21,7 +21,9 @@
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"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": {
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
@@ -45,6 +45,18 @@ export class PreferencesService {
|
||||
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 {
|
||||
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 type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
|
||||
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
|
||||
import type { StoreSelectionService } from './StoreSelectionService'
|
||||
|
||||
@@ -19,7 +20,8 @@ export class StoreProvisioningService {
|
||||
private readonly registry: StoreRegistryRepository,
|
||||
private readonly installer: StoreEngineInstaller,
|
||||
private readonly stores: InstalledStoreRepository,
|
||||
private readonly selection: StoreSelectionService
|
||||
private readonly selection: StoreSelectionService,
|
||||
private readonly catalogGateway: StoreCatalogGateway
|
||||
) {}
|
||||
|
||||
public get registryUrl (): string {
|
||||
@@ -47,4 +49,79 @@ export class StoreProvisioningService {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const store = this.stores.findByHome(home)
|
||||
if (store === null) throw new StoreMissingError(home)
|
||||
@@ -55,6 +62,20 @@ export class StoreSelectionService {
|
||||
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 {
|
||||
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. */
|
||||
readRoots: () => readonly 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>
|
||||
syncGames: (store: InstalledStore, names: readonly 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. */
|
||||
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 type { StorePaths } from '../../domain/models/StorePaths'
|
||||
import type { CredentialRepository } from '../../domain/ports/CredentialRepository'
|
||||
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
|
||||
import type { SignInPollResult, StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
|
||||
import { StoreFileSystem } from '../files/StoreFileSystem'
|
||||
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
|
||||
* 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 (
|
||||
store: InstalledStore,
|
||||
@@ -151,6 +155,34 @@ export class NativeStoreCatalogGateway implements StoreCatalogGateway {
|
||||
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> {
|
||||
const engine = this.openStore(store, {})
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -13,8 +13,10 @@ const CONFIG_FILE_NAME = 'config.json'
|
||||
* Finds stores where they were put.
|
||||
*
|
||||
* 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
|
||||
* touching a working installation, which is how this repository is tested.
|
||||
* valid stores. `STORE_ROOT` replaces the lot: a sandbox has to be a sandbox, and it
|
||||
* 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 {
|
||||
public findAll (): readonly InstalledStore[] {
|
||||
@@ -34,10 +36,11 @@ export class FileSystemInstalledStoreRepository implements InstalledStoreReposit
|
||||
}
|
||||
|
||||
public readRoots (): readonly string[] {
|
||||
const override = process.env['STORE_ROOT']
|
||||
if (override !== undefined && override.length > 0) return [override]
|
||||
|
||||
const home = os.homedir()
|
||||
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']
|
||||
if (xdgDataHome !== undefined && xdgDataHome.length > 0) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
try {
|
||||
return fs.readdirSync(root, { withFileTypes: true })
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ServiceContainer {
|
||||
|
||||
const stores = new FileSystemInstalledStoreRepository()
|
||||
const credentials = new SafeStorageCredentialRepository(environment)
|
||||
const catalogGateway = new NativeStoreCatalogGateway(credentials)
|
||||
const catalogGateway = new NativeStoreCatalogGateway(credentials, stores)
|
||||
const registry = new HttpStoreRegistryRepository(httpClient)
|
||||
const installer = new NativeStoreEngineInstaller()
|
||||
const preferencesRepository = new JsonFilePreferencesRepository(environment)
|
||||
@@ -60,7 +60,9 @@ export class ServiceContainer {
|
||||
this.selection = new StoreSelectionService(stores, preferences)
|
||||
this.catalog = new CatalogService(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.state = new ApplicationStateService(
|
||||
preferences, this.selection, this.provisioning, environment
|
||||
|
||||
@@ -4,7 +4,20 @@ import {
|
||||
asRecord, readBoolean, readNumber, readOptionalString, readString, readStringArray
|
||||
} from '../../infrastructure/json/JsonRecord'
|
||||
|
||||
const SETTLE_DELAY_MS = 6_000
|
||||
/**
|
||||
* How long to keep waiting for the window to have something on it.
|
||||
*
|
||||
* This used to be a flat six-second sleep, which is a guess about somebody else's
|
||||
* machine: on a cold start — a freshly built app, Gatekeeper checking it, a first DNS
|
||||
* lookup and the catalog still in flight — six seconds is sometimes not enough, and the
|
||||
* run reported an empty window as a failure. It was not a failure; it was a stopwatch.
|
||||
*
|
||||
* Now it polls for a settled window and only gives up at the ceiling, so the common
|
||||
* case is *faster* than the old fixed wait and the cold case still passes.
|
||||
*/
|
||||
const SETTLE_POLL_MS = 400
|
||||
const SETTLE_CEILING_MS = 30_000
|
||||
const SETTLE_DELAY_MS = 1_000
|
||||
const SWITCH_SETTLE_DELAY_MS = 8_000
|
||||
const SHOT_FRAME_DELAY_MS = 400
|
||||
|
||||
@@ -55,12 +68,14 @@ export class SelfTestRunner {
|
||||
private readonly shotPath: string | null = process.env['SELFTEST_SHOT'] ?? null
|
||||
) {}
|
||||
|
||||
/** A short first wait; `run` does the rest of the waiting itself. */
|
||||
public get settleDelayMs (): number {
|
||||
return SETTLE_DELAY_MS
|
||||
}
|
||||
|
||||
/** True when the window is in a state a user could work with. */
|
||||
public async run (): Promise<boolean> {
|
||||
await this.awaitSettled()
|
||||
const report = await this.readReport()
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
@@ -72,9 +87,12 @@ export class SelfTestRunner {
|
||||
// A gate passes on having something to do, not on having a picker: the picker
|
||||
// only appears when the registry offers more than one store, and one store is
|
||||
// the ordinary case. Requiring choices here failed a perfectly good window.
|
||||
// Both footer icons must be present, named and drawn: refresh and the language
|
||||
// picker are the only way to reach those two actions now that neither has a label.
|
||||
const iconsNamed = report.iconControls.length === 2 &&
|
||||
// Every footer icon must be named and drawn. There are three — refresh, add a
|
||||
// store, and the language picker — and none of them has a label, so an unnamed one
|
||||
// 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))
|
||||
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
||||
// version line says there is something newer. Uninstall is always available.
|
||||
@@ -93,6 +111,27 @@ export class SelfTestRunner {
|
||||
return passed
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the window is showing something, or until the ceiling.
|
||||
*
|
||||
* "Something" is a card or the gate: those are the two states a person could act on,
|
||||
* and between them they cover every way this application legitimately ends up. Timing
|
||||
* out is not treated as a failure here — the report is taken anyway, and the checks
|
||||
* below decide, so a genuinely empty window still fails for the right reason rather
|
||||
* than as a timeout with no detail.
|
||||
*/
|
||||
private async awaitSettled (): Promise<void> {
|
||||
const deadline = Date.now() + SETTLE_CEILING_MS
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await this.evaluate(
|
||||
"String(document.querySelectorAll('.card').length > 0 || " +
|
||||
"!document.getElementById('gate').hidden)"
|
||||
)
|
||||
if (ready === 'true') return
|
||||
await delay(SETTLE_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private async readReport (): Promise<SelfTestReport> {
|
||||
const record = asRecord(JSON.parse(await this.evaluate(`JSON.stringify({
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
@@ -160,9 +199,12 @@ export class SelfTestRunner {
|
||||
*/
|
||||
private async switchStore (): Promise<StoreSwitchReport> {
|
||||
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')]
|
||||
.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)}))
|
||||
return JSON.stringify({
|
||||
storeId: document.getElementById('store-id').textContent,
|
||||
@@ -206,3 +248,9 @@ export class SelfTestRunner {
|
||||
return typeof result === 'string' ? result : JSON.stringify(result ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
async function delay (milliseconds: number): Promise<void> {
|
||||
await new Promise<void>((resolve: () => void): void => {
|
||||
setTimeout((): void => { resolve() }, milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ export class StoreIpcController {
|
||||
this.handleListRegistry())
|
||||
router.handle(IPC_CHANNELS.storeInstallStore, async (store: unknown): Promise<InstalledStoreDto> =>
|
||||
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 =>
|
||||
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 {
|
||||
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>,
|
||||
installStore: async (store: RegistryStoreDto): 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> =>
|
||||
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> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.appOpenFolder, directory) as Promise<boolean>,
|
||||
|
||||
@@ -72,6 +72,7 @@ export class RendererApplication {
|
||||
})
|
||||
this.sideMenu = new SideMenuView({
|
||||
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() },
|
||||
onRefresh: (): void => { void this.catalog.refresh() },
|
||||
onSelectCategory: (filter: CategoryFilter): void => { this.store.applyFilter(filter) },
|
||||
|
||||
@@ -41,11 +41,30 @@ export class StoreController {
|
||||
const messages = state.messages
|
||||
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) {
|
||||
this.store.applyGate({
|
||||
title: messages.registryFailed,
|
||||
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
|
||||
}
|
||||
@@ -53,7 +72,9 @@ export class StoreController {
|
||||
if (result.stores.length === 0) {
|
||||
this.store.applyGate({
|
||||
title: messages.setupTitle,
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`
|
||||
body: `${messages.registryEmpty}\n\n${result.sourceUrl}`,
|
||||
custom,
|
||||
...cancel
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -67,10 +88,65 @@ export class StoreController {
|
||||
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> {
|
||||
const messages = this.store.readState().messages
|
||||
this.store.applyProgress({ total: 0, done: 0, label: messages.setupWorking })
|
||||
|
||||
+28
-1
@@ -33,7 +33,6 @@
|
||||
<section class="side-block">
|
||||
<h2 class="side-head" id="head-stores"></h2>
|
||||
<div class="store-list" id="store-list"></div>
|
||||
<button id="add-store" class="btn btn-ghost btn-wide"></button>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
@@ -66,6 +65,17 @@
|
||||
<path d="M13.5 2v3h-3" />
|
||||
</svg>
|
||||
</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 native dropdown knows how to open upward in a cramped window and is
|
||||
@@ -111,8 +121,25 @@
|
||||
<select id="gate-select" class="select"></select>
|
||||
</label>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<main id="grid" class="grid" hidden></main>
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface GateLink {
|
||||
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.
|
||||
*
|
||||
@@ -24,4 +32,12 @@ export interface GatePresentation {
|
||||
readonly action?: GateAction
|
||||
readonly link?: GateLink
|
||||
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; }
|
||||
|
||||
/* 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-row {
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
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;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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-id {
|
||||
font-size: 11px;
|
||||
@@ -141,7 +159,30 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
text-overflow: ellipsis;
|
||||
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. */
|
||||
.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:hover:not(:disabled) { background: #45cd9b; }
|
||||
.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; }
|
||||
.select {
|
||||
font: inherit;
|
||||
@@ -333,6 +381,39 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
the machine here. */
|
||||
.card.is-purchasable { opacity: 1; }
|
||||
.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; }
|
||||
|
||||
.card {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RegistryStoreDto } from '../../shared/contracts/dto/RegistryStoreDto'
|
||||
import { createElement, requireElement, setHidden, setText } from '../dom/Dom'
|
||||
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
|
||||
@@ -15,7 +15,13 @@ export class GateView {
|
||||
private readonly choiceLabel = requireElement('gate-choice-label', HTMLElement)
|
||||
private readonly select = requireElement('gate-select', HTMLSelectElement)
|
||||
private readonly button = requireElement('gate-action', HTMLButtonElement)
|
||||
private readonly cancel = requireElement('gate-cancel', HTMLButtonElement)
|
||||
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) {}
|
||||
|
||||
@@ -25,6 +31,8 @@ export class GateView {
|
||||
setText(this.body, presentation.body)
|
||||
this.renderChoices(presentation.choices ?? [], messages)
|
||||
this.renderAction(presentation)
|
||||
this.renderCancel(presentation)
|
||||
this.renderCustom(presentation.custom ?? 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 {
|
||||
setHidden(this.link, link === null)
|
||||
if (link === null) return
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AppState } from '../state/AppStore'
|
||||
|
||||
export interface SideMenuViewCallbacks {
|
||||
readonly onSelectStore: (home: string) => void
|
||||
readonly onRemoveStore: (home: string, name: string) => void
|
||||
readonly onAddStore: () => void
|
||||
readonly onRefresh: () => void
|
||||
readonly onSelectCategory: (filter: CategoryFilter) => void
|
||||
@@ -57,7 +58,9 @@ export class SideMenuView {
|
||||
public render (state: AppState): void {
|
||||
setText(this.storesHead, state.messages.stores)
|
||||
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
|
||||
// writing text into them would replace the glyph.
|
||||
describeControl(this.refresh, state.messages.refresh)
|
||||
@@ -84,15 +87,30 @@ export class SideMenuView {
|
||||
.map((store: InstalledStoreDto): string => store.id))
|
||||
|
||||
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')
|
||||
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))
|
||||
row.title = store.home
|
||||
row.addEventListener('click', (): void => {
|
||||
open.title = store.home
|
||||
open.addEventListener('click', (): void => {
|
||||
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
|
||||
}))
|
||||
}
|
||||
@@ -168,6 +186,26 @@ export class SideMenuView {
|
||||
* `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.
|
||||
*/
|
||||
/**
|
||||
* 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 {
|
||||
element.title = 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>
|
||||
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>
|
||||
/** Uninstall everything the store put here, then remove the store itself. */
|
||||
removeStore: (home: string) => Promise<void>
|
||||
|
||||
openFolder: (directory: string) => Promise<boolean>
|
||||
openUrl: (url: string) => Promise<boolean>
|
||||
|
||||
@@ -26,7 +26,9 @@ export const IPC_CHANNELS = {
|
||||
|
||||
storeListRegistry: 'store:listRegistry',
|
||||
storeInstallStore: 'store:installStore',
|
||||
storeInstallCatalog: 'store:installCatalog',
|
||||
storeSelectStore: 'store:selectStore',
|
||||
storeRemoveStore: 'store:removeStore',
|
||||
|
||||
/** Main to renderer, one way. */
|
||||
streamLog: 'stream:log',
|
||||
|
||||
@@ -25,6 +25,14 @@ export const ENGLISH_MESSAGES = {
|
||||
menu: 'Menu',
|
||||
stores: 'Stores',
|
||||
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',
|
||||
categories: 'Categories',
|
||||
catAll: 'Everything',
|
||||
|
||||
@@ -25,6 +25,14 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
menu: 'Menü',
|
||||
stores: 'Store-ok',
|
||||
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',
|
||||
categories: 'Kategóriák',
|
||||
catAll: 'Minden',
|
||||
|
||||
Reference in New Issue
Block a user